gabriel / muse public
EXTREME_STRESS_PLAN.md markdown
1,516 lines 80.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago

Muse — Extreme Stress, Data Integrity, Security & Performance Plan

Status legend: ⬜ pending · 🔄 in progress · ✅ done · ❌ blocked

This document is the living implementation checklist for the Muse extreme hardening campaign. Work proceeds in three phases, in priority order:

  1. Data Integrity — a single lost or corrupted bit stops adoption on day one.
  2. Security — agents run in sensitive environments; leakage or escalation is fatal.
  3. Performance — trillion-agent scale; every microsecond compounds.

We drive the plan by imagining Linus Torvalds migrating the Linux kernel to Muse: ~75 000 files, ~850 000 commits, hundreds of concurrent contributors, a full CI fleet. If Muse can hold that, it can hold anything.


Pre-flight: What muse told us

Before writing a single test, we used the semantic porcelain to map the attack surface. Key findings:

Finding Source
read_object does not re-verify SHA-256 on read muse code cat object_store.py::read_object
_write_msgpack_atomic has no fsync before rename muse code cat store.py::_write_msgpack_atomic
_read_msgpack has no size limit muse code cat store.py::_read_msgpack
_write_msgpack_atomic uses fixed .tmp suffix — concurrent write race muse code cat store.py::_write_msgpack_atomic
get_all_commits loads every commit into memory with no cap muse code cat store.py::get_all_commits
walk_commits_between caps at 10 000 — Linux kernel has 850 000 muse code cat store.py::walk_commits_between
find_merge_base caps ancestor graph at 50 000 muse code cat merge_engine.py::find_merge_base
write_commit silently skips if ID exists — no integrity check of existing record muse code cat store.py::write_commit
Object store shards on 2-char prefix — 256 shards × ~3 300 objects at Linux scale object store analysis
MAX_FILE_BYTES = 256 MiB in object store; no equivalent cap in msgpack reads muse code cat object_store.py::read_object

Phase 1 — Data Integrity

1.1 Read-time hash verification ✅

Problem: read_object validates the object_id format but reads the file without re-verifying the SHA-256 of the bytes returned. A single bit flip (disk error, filesystem bug, malicious tamper) is served silently.

Test plan:

  • [x] Write an object, flip one bit in the stored file, call read_object — confirmed it previously returned corrupt bytes without error.
  • [x] After fix: same scenario raises OSError and logs at CRITICAL.
  • [x] Stress: flip bits at byte 0, byte -1, middle byte — all positions caught.
  • [x] muse plumbing cat-object <oid> surfaces the same error to the agent.
  • [x] muse plumbing verify-pack catches bit-flipped objects in local store (fix: replaced has_object() existence check with read_object() hash-verified read — corrupt local objects are now reported as kind="object" failures).
  • [x] Benchmark: 256 MiB object re-hash confirmed < 500 ms (passes on tmpfs).

Additional coverage added (gap audit):

  • [x] TestCriticalLogOnCorruption — verifies CRITICAL log is emitted and contains the object ID; verifies no false alarm on clean reads.
  • [x] TestMaxFileBytesLimit — verifies the size guard fires before any read, the boundary value is allowed, and the error message includes the MiB limit.
  • [x] TestVerifyPackLocalStoreIntegrity — four tests covering the has_object→read_object fix: clean passes, bit-flip fails, zero-fill fails, --no-local correctly skips the integrity check.
  • [x] TestVerifyObjectAllCorruptverify-object --all catches one corrupt object among many, catches multiple simultaneous corruptions, and reports failures keyed by exact object ID.
  • [x] TestPerformanceBenchmark — 256 MiB < 500 ms, 1 MiB < 10 ms.

Fix: read_object streams in 64 KiB chunks, accumulates SHA-256, raises OSError on mismatch and logs at CRITICAL. cat-object raw mode does a two-pass verification before emitting any bytes. verify-pack now calls read_object() instead of has_object() for local store objects.


1.2 fsync before atomic rename ✅

Problem: _write_msgpack_atomic and write_object use os.replace(tmp, dest) without fsync-ing the temp file first. On macOS (APFS), Linux (ext4/btrfs), and any journaling filesystem, a power loss between the kernel accepting the rename() syscall and flushing the page cache can result in a zero-length or partially-written file at the destination — even though os.replace() returned successfully.

Test plan:

  • [x] Simulate crash by patching os.replace to raise OSError mid-write — no partial file remains.
  • [x] Page-cache non-flush defense-in-depth: zeroing the stored file after a successful write (simulating post-rename flush loss) is caught by I-1 hash check. TestPageCacheDefenseInDepth (3 tests).
  • [x] fh.flush(); os.fsync(fh.fileno()) added before os.replace() in write_object, _write_msgpack_atomic, and write_text_atomic. mkstempfsyncrename pattern verified in all Tier 0–3 write paths.
  • [x] Benchmark: 4 KiB msgpack write + fsync < 5 ms confirmed. TestFsyncWritePerformance (3 perf-marked tests).
  • [x] Stress: 10 000 sequential commits — all readable, zero orphans. 1 000 commits with 20% flaky-fsync rate — all land correctly. TestSequentialStress (2 slow-marked tests).
  • [x] SIGKILL crash safety: multiprocessing kill mid-write — pre-kill objects remain readable; store is consistent. TestProcessKillCrashSafety (1 slow test).

Additional coverage added (gap audit):

  • [x] TestWriteObjectFromPathFsync (5 tests) — _fsync_path ordering, non-fatal failure, shutil.copy2 failure orphan cleanup, replace failure orphan cleanup, content round-trip.
  • [x] TestRestoreObjectFsync (4 tests) — same pattern for restore_object.
  • [x] TestIdempotentConcurrentWrite (2 tests) — 50 threads writing the same object_id simultaneously; wrong-content write rejected.
  • [x] TestMidWriteFailureCleanup (2 tests) — OSError during fh.write itself (not just at replace) leaves no orphan in write_object or write_text_atomic.

Fix: write_object: mkstempfdopenflush_fsync_fdos.replace. _write_msgpack_atomic and write_text_atomic: same mkstempflushfsynctmp.replace pattern. write_object_from_path and restore_object: mkstempshutil.copy2_fsync_pathos.replace. All write paths now use unique temp names and fsync before rename.

Total coverage: 85 tests across 19 classes (81 standard, 4 slow/perf). All green. mypy and typing_audit both at zero violations.


1.3 Concurrent write race in _write_msgpack_atomic

Problem: The temp file is always path.with_suffix(".tmp") — a fixed sibling name. Two threads or processes writing different records to the same path concurrently will both write to the same .tmp file, with the last writer winning and potentially corrupting both records.

In practice, commits have unique IDs so two concurrent write_commit calls will never target the same .msgpack path. But write_head_branch, write_head_commit, and write_tag do write to shared paths — HEAD, refs/heads/<branch>, tags/<tag>.msgpack — and these can race.

Fix: _write_msgpack_atomic and write_text_atomic both use tempfile.mkstemp(dir=..., prefix=".muse-tmp-"). The kernel guarantees mkstemp names are unique per process; combined with os.replace (atomic at the VFS level), concurrent writers on the same target path each write their own temp file and the last rename wins cleanly — no cross-thread temp collision, no torn write.

Test plan (all complete):

  • [x] Regression proof — simulates the OLD fixed-.tmp approach to show it CAN produce race failures (FileNotFoundError or silent data loss depending on timing). Proves the fix was necessary.
  • [x] mkstemp uniqueness invariant — 50 concurrent mkstemp calls produce 50 distinct names. Proves the OS guarantee that underpins the entire fix.
  • [x] 50 threads write_head_commit — final HEAD is always a valid commit: <64-hex> string; no torn prefix ever observed.
  • [x] 50 threads write_head_commit reader — continuous reader thread sees only valid HEAD values during 50 concurrent writes.
  • [x] 100 threads same branch refwrite_branch_ref("main") with 100 concurrent writers; final value always a valid 64-char hex string.
  • [x] 100-thread branch ref reader — concurrent reader never sees a partial commit ID while 100 writers race.
  • [x] Mixed HEAD race — 25 write_head_branch + 25 write_head_commit threads; HEAD always valid after every interleaved write.
  • [x] Amplified race window write_text_atomic — 100 threads with 1ms sleep injected before os.replace; final content is always one complete payload, never a torn mix.
  • [x] Amplified race window write_head_commit — same 1ms sleep, 100 threads writing distinct commit IDs; HEAD always valid.
  • [x] Amplified race window write_branch_ref — same pattern on branch refs.
  • [x] 50 distinct concurrent write_tag calls — all 50 tags persist correctly; zero orphan temp files.
  • [x] 100 threads same tag path — last-write-wins; stored tag is fully parseable, commit ID valid.
  • [x] Orphan cleanup write_tag — zero .muse-tmp-* files after 50 concurrent tag writes.
  • [x] Reader + 200 concurrent HEAD writers — reader never sees a torn value during 100 write_head_commit + 100 write_head_branch concurrent writes.
  • [x] Reader + concurrent write_commit — reader checking commit records always sees complete, correct records.
  • [x] write_text_atomic 100-thread same path — final content is always one complete payload.
  • [x] Temp name uniqueness in write_text_atomic — 50 concurrent calls produce 50 distinct mkstemp names; zero collisions.

Coverage added: tests/test_integrity_I3_concurrent_race.py (18 tests across 8 classes: TestFixedTmpRegressionProof, TestWriteHeadCommitConcurrent, TestWriteBranchRef100Threads, TestMixedHeadRace, TestAmplifiedRaceWindow, TestConcurrentTagWrites, TestReaderDuringConcurrentWrites, TestWriteTextAtomicRace). All 18 pass, zero warnings, mypy+typing_audit clean.


1.4 Msgpack read size limit ✅

Problem: _read_msgpack called path.read_bytes() with no size check. A malicious or corrupted .msgpack file that is 10 GiB would OOM the process. read_object had a 256 MiB cap; the commit/snapshot/tag/release and index stores did not.

Fix applied to two modules:

  • muse/core/store.py: MAX_MSGPACK_BYTES = 64 MiB. _read_msgpack checks path.stat().st_size before read_bytes() — no allocation occurs on oversized files. Per-value limits on msgpack.unpackb: max_str_len=1 MiB, max_bin_len=0 (no binary in commit/snapshot/tag records — rejects tampered files), max_array_len=1 000 000, max_map_len=1 000 000.

  • muse/core/indices.py: _MAX_INDEX_BYTES = 512 MiB (more generous — indices grow with repo size). Same per-value limits with max_bin_len=1 MiB (indices may store binary body hashes).

Test plan (all complete):

  • [x] Constant exportMAX_MSGPACK_BYTES importable, equals 64 MiB, is less than the 256 MiB object store cap.
  • [x] Stat before read_bytes — Proof that read_bytes() is never called for an oversized file (mocked stat, tracked read_bytes call — not called).
  • [x] read_commit returns None for oversized file — not OOM, not crash.
  • [x] read_snapshot returns None for oversized file.
  • [x] get_all_tags skips oversized tag files, preserves good tags.
  • [x] list_releases skips oversized release files.
  • [x] Exact boundary: file at limit passes (parse error, not size error).
  • [x] One byte over limit raises OSError with filename and size in message.
  • [x] Zero-byte file passes size check, fails on parse error.
  • [x] Error message includes filename and limit in MiB.
  • [x] max_str_len enforced — string > 1 MiB rejected by unpackb.
  • [x] max_bin_len=0 enforced — binary blob in store record raises on unpack.
  • [x] max_map_len enforced — oversized map raises on unpack.
  • [x] max_array_len enforced — oversized array raises on unpack.
  • [x] 10 000-deep nested map raises StackError — msgpack's internal guard.
  • [x] Deeply nested document terminates in < 1 s — no infinite loop, no OOM.
  • [x] 1000-file snapshot manifest reads correctly — generous limits not over-tight.
  • [x] Index stat check fires before read_bytes — proven via mock.
  • [x] load_symbol_history skips oversized index — returns empty dict.
  • [x] load_hash_occurrence skips oversized index — returns empty dict.
  • [x] Index limit is larger than store limit — invariant checked.
  • [x] Warning logged on oversized commit/snapshot — monitoring surfaces it.
  • [x] muse plumbing read-commit with oversized file → clean JSON error, no Python traceback, exit code non-zero.
  • [x] Round-trip unaffected — valid commits, snapshots, large manifests, long messages all read correctly.
  • [x] Performance: 100 sequential reads < 100 ms — stat overhead < 1 ms each.
  • [x] Oversized rejection < 1 ms per call — 1000 rejections in < 1 s.

Coverage added: tests/test_integrity_I4_msgpack_size.py (34 tests across 9 classes). All 34 pass. mypy + typing_audit clean on all affected files.


1.5 Commit record integrity on re-read ✅

Problem: write_commit skips if path.exists() — correctly idempotent. But it does not verify that the existing record matches the incoming CommitRecord. If a commit is written once, then a different agent writes a commit with a colliding ID (impossible with SHA-256 in theory, but possible via a bug or malicious injection), the second write is silently dropped and the wrong record persists.

Additionally, read_commit logs a warning and returns None on corrupt files — but the caller cannot distinguish "never existed" from "existed and is corrupt".

Fix implemented:

write_commit — three-path idempotency check:

  1. File exists + parseable + commit_id matches → normal skip (unchanged).
  2. File exists + corrupt → CRITICAL log + overwrite with incoming good record.
  3. File exists + parseable but commit_id mismatch → OSError("Store integrity violation") — tamper detection.

read_commit / read_snapshot / read_releaselogger.warninglogger.critical for all corrupt files.

get_all_commits / get_all_tags / list_releases — previously silently swallowed corrupt files with zero log. Now emit CRITICAL for every corrupt record skipped.

New API surface:

  • CommitReadOk, CommitReadNotFound, CommitReadCorrupt TypedDicts.
  • read_commit_result() — typed discriminated union: "ok" / "not_found" / "corrupt".
  • commit_read_is_ok/not_found/corrupt()TypeGuard narrowing functions (zero type: ignore).
  • SnapshotReadOk, SnapshotReadNotFound, SnapshotReadCorrupt TypedDicts.
  • read_snapshot_result() + snapshot_read_is_ok/not_found/corrupt() — same pattern.

Test coverage (40 tests — tests/test_integrity_I5_commit_integrity.py):

  • [x] First writer wins; second write with different message silently dropped.
  • [x] Corrupt existing file (garbage bytes, empty, truncated) → CRITICAL + overwrite.
  • [x] commit_id mismatch in stored record → OSError raised.
  • [x] read_commit logs at level CRITICAL (50) — never WARNING (30).
  • [x] read_commit_result returns all three statuses with correct key contracts.
  • [x] TypeGuard functions provide zero-type:ignore narrowing.
  • [x] read_snapshot / read_snapshot_result — full parity.
  • [x] get_all_commits / get_all_tags / list_releases — CRITICAL on corrupt.
  • [x] muse plumbing read-commit CLI roundtrip verifies written records.
  • [x] 50-thread concurrent write storm: first winner always survives; 50 distinct IDs all persist.

1.6 Linux-kernel scale: snapshot manifest ✅

Scenario: Linus runs a git-to-muse migration script. The Linux kernel tree has ~75 000 tracked files. build_snapshot_manifest calls walk_workdir which hashes every file. At ~1 GB/s SHA-256 throughput and average 50 KiB per file, hashing 75 000 files takes:

75 000 × 50 KiB = 3.75 GiB ÷ 1 GB/s = 3.75 s

That is acceptable for an initial commit. But every subsequent muse commit re-hashes the entire tree unless there is inode/mtime caching.

Fix implemented:

  • JSON → msgpack (v2): stat_cache.json replaced by stat_cache.msgpack (version 2). At 75k entries, msgpack serialization is 3–4× faster than JSON (~50 ms vs ~200 ms) and ~30% smaller on disk.
  • st_ino added to cache key: Cache entry now stores (ino, mtime, size). The previous (mtime, size)-only key had a TOCTOU false-positive: an atomically replaced file with identical mtime and size (common from build tools and muse checkout) served a stale hash. New inode → definite miss.
  • mkstemp in save(): Eliminates the fixed .tmp suffix race where two concurrent muse commit processes clobbered each other's temp file.
  • fsync in save(): Consistent with I-2 durability principles.
  • MAX_CACHE_BYTES (256 MiB) size limit on load: I-4-style defense — a tampered or runaway cache file cannot OOM the process.
  • All get_cached() call sites updated (snapshot.py + code, midi, scaffold plugins) to pass st.st_ino.

Test coverage (tests/test_integrity_I6_snapshot_scale.py — 27 tests):

  • TestCacheFormat (5) — msgpack v2 on disk, ino field present, version mismatch / corrupt / absent cache all return empty
  • TestInodeInvalidation (3) — atomic replace detected, ino-only miss, unchanged file is genuine cache hit
  • TestConcurrentSaveSafety (2) — 20-thread concurrent save, no stray .tmp
  • TestCacheSizeLimit (3) — MAX_CACHE_BYTES exported, oversized file logs CRITICAL and returns empty, exact-limit accepted
  • TestPruneAfterWalk (1) — deleted files evicted from cache
  • TestPathSafety (3) — symlinks, FIFOs, .muse/ all excluded
  • TestWarmWalkPerformance (2) — 100-file warm walk zero misses; 1000-file warm walk < 500 ms (@slow)
  • TestDeepNesting (2) — 100-level nesting no recursion error; 50-level POSIX paths correct
  • TestLargeFileStreaming (2) — 50 MiB file RSS delta < 10 MiB (streaming proven, @slow); zero-byte file hashes correctly
  • TestLinuxKernelScale (4, @slow) — 75k cold walk correct + < 30 s; 75k warm walk zero cache misses; ≥ 5× speedup proven with 100 × 1 MiB files; partial-change only re-hashes modified files

Key discovery: On macOS APFS, os.lstat dominates both cold and warm walks at 75k scale (~50 μs × 75k = 3.75 s). Speedup ratio with tiny files is ~1.7× because lstat is unavoidable on both paths. With I/O-bound files (≥ 1 MiB), the speedup is ≥ 14× because warm walk skips all open() calls. Tests use the appropriate proof for each scenario.


1.7 Linux-kernel scale: commit history walk ✅

Scenario: After migrating 850 000 Linux commits, walk_commits_between caps at 10 000. find_merge_base caps at 50 000 ancestors. Both will silently truncate on the full kernel history.

Fix implemented:

Beyond the explicit plan, muse recon surfaced five additional issues:

  1. O(n²) BFS bug: _collect_all_commits used list.pop(0) — O(n) per pop, making the full graph BFS O(n²). Fixed to deque.popleft() — O(1).
  2. _collect_all_commits unbounded: --graph --all had no safety cap. Added max_graph_commits (configurable, default 50k) with warning banner.
  3. find_merge_base B-side silent truncation: A-side raised MuseCLIError, B-side returned None silently — inconsistent. Both now raise the same error.
  4. muse log --json batch output: entire history buffered before emit. Now streams one commit at a time — zero extra memory per additional commit.
  5. commit_graph text/count-only formats missing truncated: JSON had it; text and count-only did not. Fixed for all three output formats.
  6. All caps hardcoded: now read from [limits] section in config.toml via get_limit(). Keys: max_walk_commits, max_ancestors, max_graph_commits.

New types / functions:

  • WalkResult(TypedDict) in store.py{commits, truncated, count}
  • walk_commits_between_result() — same walk, returns WalkResult
  • LimitsConfig(TypedDict) in config.py
  • get_limit(key, repo_root) — reads configurable cap or returns default
  • _DEFAULT_MAX_WALK_COMMITS, _DEFAULT_MAX_ANCESTORS, _DEFAULT_MAX_GRAPH_COMMITS

Test coverage (tests/test_integrity_I7_history_walk.py — 39 tests):

  • WalkResult TypedDict shape, backward-compat list return
  • 15k chain truncated at default cap (10k) — @slow
  • 15k chain completes with raised cap (20k) — @slow
  • 60k branches: find_merge_base raises, never returns wrong answer — @slow
  • Configurable caps: all three keys from config.toml, invalid values ignored
  • get_config_value("limits.max_walk_commits") reads correctly
  • AST-validated O(1) BFS: confirms pop(0) absent at call-site level
  • _collect_all_commits tuple return shape, truncation at cap
  • 10k BFS completes in < 5s (O(n) proof) — @slow
  • commit-graph: truncated=true in JSON, text (# TRUNCATED), count-only
  • 15k commit-graph completes in < 30s — @slow
  • muse log --json: has truncated field, has commits array, correct fields
  • from_commit_id exclusion correctness; full walk without truncation

1.8 Object store at Linux scale ✅

Scenario: 850 000 commits × ~20 objects per commit = 17 million objects. The object store shards on the first 2 hex chars = 256 shards × ~66 000 files per shard. On Linux ext4, directory entry lookup degrades above ~100 000 files per directory.

Fix implemented:

  • 0o444 mode bug fixed (confirmed: existing objects were 0o600). write_object and write_object_from_path now call os.chmod(tmp, 0o444) before os.replace, so every object lands read-only atomically. Direct open(path, 'wb') now raises PermissionError — the OS enforces content-addressability.

  • Configurable 4-char sharding added: [limits] shard_prefix_length = 4 in .muse/config.toml switches to 65 536 shards (vs 256). At 17M objects, 4-char sharding gives ~260 files/shard — well under ext4's 100k danger zone. get_limit("shard_prefix_length") reads the config; default is 2.

  • Dual-lookup / migration fallback (_object_path_with_fallback): objects written at the 2-char path are transparently found after switching to 4-char — no migration script required.

  • cleanup_stale_object_temps: removes .obj-tmp-* and .restore-tmp-* files left by a prior SIGKILL. Idempotent and safe across all shard dirs.

  • 5 issues found beyond the plan:

    1. 0o600 mode on all existing objects (confirmed via live store inspection).
    2. Both write paths (write_object + write_object_from_path) lacked chmod.
    3. No stale temp cleanup anywhere in the codebase.
    4. object_path() had no prefix_len param — callers couldn't opt into 4-char.
    5. 29 corruption tests in I1/I2 broke immediately (proof the mode guard works) — fixed with _corrupt_file helpers that temporarily lift 0o444.
  • has_object O(log n) proof: ext4 and APFS use hash-tree/B-tree indexing. Tests confirm lookup at 100k files/shard stays < 10 ms and growth from 1k→10k is < 5×. Lookup is O(log n), not O(n).

Test coverage (tests/test_integrity_I8_object_store_scale.py):

  • TestObjectMode (6 tests) — 0o444 on both write paths, constant value, round-trip reads, direct overwrite raises PermissionError, batch verify.
  • TestStaleTempCleanup (6 tests) — removes .obj-tmp-* and .restore-tmp-*, preserves real objects, handles missing store, idempotent, multi-shard.
  • TestHasObjectPerformance (3 tests) — < 10 ms at 100k/shard, sub-linear growth proof (1k→10k ratio < 5×), negative lookup also < 10 ms.
  • TestFourCharSharding (11 tests) — default is 2, config sets 4, path layout, round-trip, mode, 65 536 shard space, invalid values ignored, get_config_value.
  • TestMigrationFallback (4 tests) — 2-char objects found after 4-char switch, fallback path returned correctly, primary preferred, idempotent re-write.
  • TestObjectIdSecurity (7 tests) — path traversal, uppercase, wrong length, non-hex chars, empty string, slash injection all rejected.
  • TestShardScaleSmoke (3 tests) — 256 shards coexist, 4-char dir is 4 chars, filename is correct 60-char remainder.
  • TestLargeScaleMode (@slow, 1 test) — 100k writes, all confirmed 0o444.

1.9 Crash safety: SIGKILL simulation ✅

Scenario: A SIGKILL arrives between mkstemp and os.replace in any of Muse's three atomic write primitives (write_object, write_text_atomic, _write_msgpack_atomic, StatCache.save). The OS never calls atexit handlers or finally blocks — the rename never happens and a stale temp file is left on disk. On the next command invocation the repo must be self-healing.

Fix implemented:

  • _startup_gc(repo_root) in muse/core/repo.py — sweeps all three stale-temp families on every require_repo() call (184 callers = every command):

    • .obj-tmp-* / .restore-tmp-* — object-store shard directories (delegated to the existing cleanup_stale_object_temps)
    • .muse-tmp-* — created by write_text_atomic and _write_msgpack_atomic in .muse/ root and every subdirectory (branches, commits, snapshots, tags, releases, refs/heads, code, indices, coordination, worktrees, stash, rr-cache, logs, remotes)
    • .stat_cache_*.tmp — created by StatCache.save in .muse/
  • _cleanup_muse_dir_temps(muse_dir) — new helper that iterates the known _MUSE_SWEEP_DIRS set (never touches objects/, which is handled separately) and unlinks any file whose name starts with a prefix in _MUSE_TEMP_PREFIXES.

  • objects_dir docstring corrected — the old docstring falsely claimed cleanup happens on first access; the actual cleanup now happens in require_repo and the docstring reflects this.

  • Discovery beyond the plan: Five additional temp families were identified beyond .obj-tmp-*: .muse-tmp-* in all store subdirs, .stat_cache_*.tmp, and .restore-tmp-* in shard dirs. All five are now swept. Working-tree .restore-tmp-* (outside .muse/) are documented as out-of-scope (they are inert and cannot corrupt the repo).

  • Push safety confirmed: write_object is content-addressed and atomic; a partial push leaves the remote object store in a consistent state. The remote-side startup GC sweeps any orphan object temps on the next command.

Test coverage (tests/test_integrity_I9_sigkill.py — 52 tests, 12 classes):

  • TestCleanupMuseDirTemps (12) — unit tests for _cleanup_muse_dir_temps: removes temps from root, commits, branches, snapshots, tags, releases; preserves real files; handles multiple stale files; handles missing dir; idempotent; exports _MUSE_TEMP_PREFIXES / _MUSE_SWEEP_DIRS constants
  • TestStartupGcObjectTemps (5) — .obj-tmp-* and .restore-tmp-* swept by _startup_gc; real objects preserved; stale and real coexist correctly
  • TestStartupGcMuseTemps (7) — .muse-tmp-* swept from all subdirs; real .msgpack files preserved
  • TestStartupGcStatCacheTemps (3) — .stat_cache_*.tmp swept; real stat_cache.msgpack preserved; multiple tmps all swept
  • TestRequireRepoCallsGc (5) — require_repo triggers sweep; all families swept in a single call; non-repo path still raises correctly
  • TestMultipleSigkills (2) — 3 crash generations × 5 stale files = 15 files all swept; count accurate
  • TestSigkillAtTimingWindows (6, @slow) — subprocess SIGKILL at T+50ms, T+100ms, T+200ms for both object-store writes and write_text_atomic loops; startup GC clears all orphans; pre-kill objects readable
  • TestSigkillDuringCommit (2, @slow) — full CLI muse commit SIGKILL; muse status exits 0; no stale temps after next command
  • TestSigkillDuringPush (3, @slow) — push SIGKILL leaves no corruption on remote; write_object idempotency confirmed; partial write → no dest
  • TestGcPreservesRealObjects (3) — 100 real objects survive GC; HEAD + config survive; empty repo is no-op
  • TestGcSweeperPerformance (2, @slow) — 1 000 stale files swept in < 500 ms; full _startup_gc with 200 stale files < 200 ms
  • TestRestoreTempWorkdirBound (2) — working-tree .restore-tmp-* not swept (documented scope); shard-dir .restore-tmp-* IS swept

1.10 Bit-flip simulation ✅

The ultimate data integrity test: can Muse detect and report every single-bit corruption in the object store?

Outcome: All three test scenarios implemented and passing in tests/test_integrity_I10_bit_flip.py. A critical silent-corruption bug was discovered and fixed during this phase: read_commit and read_snapshot were not verifying content-hashes on read, allowing tampered records to pass through undetected. Content-hash verification was added to both functions.

Test plan:

  • ✅ For a 1 MiB object: flip every bit position (8 388 608 flips) and confirm read_object (after fix 1.1) catches 100% of them.
  • ✅ For a commit .msgpack file: flip every bit and confirm either msgpack.UnpackException is raised or the deserialized record fails structural validation.
  • ✅ Fuzz: random multi-bit corruption (5 bits flipped at random positions, 10 000 iterations) — confirm zero silent corruptions pass through.

Side effect — test suite hardening (2026-03-26): The content-hash verification introduced by this phase caused widespread failures across the existing test suite: ~700 tests had been writing CommitRecord / SnapshotRecord objects with hardcoded synthetic IDs (e.g. "aa" * 32) that no longer pass verification. All affected tests were fixed to use compute_commit_id / compute_snapshot_id with pinned UTC timestamps, restoring the full suite to green (7 250 passed).

Performance note: The full suite now runs in ~28 minutes, dominated by the extreme stress tests. For day-to-day iteration, use muse code test to run only the subset covering changed files — it skips the stress suite automatically.


Phase 2 — Security

2.1 Path traversal in all plumbing inputs ✅

Every plumbing command that accepts a file path must reject paths that escape the repo root or inject malicious content into the store.

Recon performed using muse code cat, muse code grep, muse code impact:

Command Attack vector found Severity
hash-object Named pipes / sockets / block devices — would block or misbehave High
hash-object Null bytes in path argument Medium
ls-files --path-prefix No null-byte or glob-metacharacter validation Medium
check-attr / check-ignore .. traversal, absolute paths, control chars, CRLF stdin Medium
verify-object --stdin CRLF: rstrip("\\n") left \\r embedded in IDs Low
apply_pack / unpack-objects Zip-slip: manifest keys like ../../etc/cron.d/evil stored verbatim Critical
apply_pack Malicious object IDs in bundle not caught — uncaught ValueError High

New validation primitives added to muse/core/validation.py:

  • validate_workspace_path(path) — rejects empty, null bytes, control chars (except tab), absolute paths, .. traversal, and paths > 4096 chars.
  • validate_path_prefix(prefix) — rejects null bytes, control chars, and glob metacharacters (*, ?, [, ], {, }).

Fixes applied:

  • hash-object — added special-file guard (path.is_file() check); named pipes, block/char devices, and sockets are now rejected with a clear error before any read attempt.
  • ls-files --path-prefixvalidate_path_prefix gates the prefix arg.
  • check-attr — replaced ad-hoc null-byte check with validate_workspace_path; fixed CRLF stdin (rstrip("\\r\\n")).
  • check-ignore — same as check-attr.
  • verify-object --stdin — CRLF fixed: rstrip("\\r\\n").
  • apply_packzip-slip closed: all manifest keys validated with validate_workspace_path and all manifest values validated with validate_object_id before any snapshot is written to the store.
  • apply_pack — malicious object IDs now caught: ValueError from write_object is caught per-entry and logged as a warning (skipped).

Test plan (per command):

  • ../../../etc/passwd — rejected by check-attr, check-ignore, apply_pack.
  • ✅ Null-byte injection: foo\\x00../../etc/passwd — rejected by all commands.
  • ✅ Symlink pointing to regular file — allowed (documented; consistent with git hash-object); symlink to non-existent target rejected.
  • ✅ Absolute path outside repo — rejected by check-attr, check-ignore.
  • ✅ Very long path (100 000 chars) — rejected; no crash.
  • ✅ Named pipe / char device rejection in hash-object.
  • ✅ Glob metacharacters in --path-prefix rejected.
  • ✅ CRLF-poisoned stdin handled correctly in check-attr, check-ignore, verify-object.
  • ✅ 100 malicious manifest bundles — all rejected by apply_pack (stress).
  • ✅ 10 000-path batch through check-attr — no crash or timeout (stress).
  • ✅ ANSI escape injection in path strings — rejected before output.

Test file: tests/test_security_path_traversal.py (67 tests)

Commands audited: hash-object, cat-object (clean), ls-files, pack-objects (clean), unpack-objects, check-ignore, check-attr, verify-object, apply_pack (core).


Problem: If .muse/ or any file inside it is a symlink pointing outside the repo, an attacker can redirect writes to arbitrary filesystem locations.

Recon (via muse code grep + muse code impact): Five distinct attack surfaces identified across three modules:

  1. .muse/ itself symlinkedfind_repo_root called is_dir() which follows symlinks; entire repo would be redirected.
  2. .muse/objects/ (or shard dirs) symlinkedwrite_object called dest.parent.mkdir() then mkstemp(dir=dest.parent), writing into the symlink target without any check.
  3. .muse/commits/, .muse/snapshots/, etc. symlinked — same TOCTOU window in _write_msgpack_atomic and write_text_atomic.
  4. Cleanup functions entering symlinked dirscleanup_stale_object_temps and _cleanup_muse_dir_temps iterated shard/subdir entries without checking for symlinks, potentially deleting files in attacker-controlled locations.
  5. Tracked-file symlinks — already safe (os.lstat + S_ISREG filter in build_snapshot_manifest silently skips symlinks); documented.

Fixes applied:

File Fix
muse/core/validation.py Added assert_not_symlink(path, label) — raises ValueError if path is a symlink; added assert_write_inside_repo(repo_root, path) — raises ValueError if path resolves outside repo root
muse/core/repo.py find_repo_root now checks not muse_dir.is_symlink() in both normal walk and MUSE_REPO_ROOT env override; added _CRITICAL_MUSE_DIRS constant and _verify_muse_dir_integrity which checks all critical subdirs on startup; _cleanup_muse_dir_temps skips symlinked subdirs
muse/core/object_store.py write_object and write_object_from_path call assert_write_inside_repo before mkdir (preventing even directory creation in attacker space) and assert_not_symlink after mkdir (TOCTOU guard); cleanup_stale_object_temps skips symlinked shards
muse/core/store.py write_text_atomic and _write_msgpack_atomic call assert_not_symlink(path.parent) before mkstemp

Test coverage (40 tests in tests/test_security_symlink.py):

  • [x] .muse/HEAD is a symlink → write_text_atomic replaces symlink entry (POSIX-safe, documented)
  • [x] .muse/objects/ symlinked → write_object raises before any filesystem modification
  • [x] .muse/objects/<shard>/ symlinked → write_object raises, attacker dir stays empty
  • [x] .muse/commits/ symlinked → _write_msgpack_atomic raises, attacker dir stays empty
  • [x] All 6 critical subdirs (objects, commits, snapshots, refs, refs/heads, tags) → _verify_muse_dir_integrity exits on any symlink
  • [x] find_repo_root rejects .muse/ symlinks in both normal walk and MUSE_REPO_ROOT env override
  • [x] Chain-of-symlinks (symlink-to-symlink) rejected
  • [x] Dangling symlink rejected
  • [x] Cleanup functions skip symlinked shard/subdirs — victim files untouched
  • [x] Tracked-file symlinks silently excluded from build_snapshot_manifest
  • [x] assert_write_inside_repo catches resolved-path escape
  • [x] 50-thread concurrent symlink-swap stress: attacker dir stays empty throughout
  • [x] E2E: muse status rejects symlinked .muse/ and accepts real .muse/

2.3 Object store poisoning ✅

Problem: write_object verifies the hash before writing. But what if an attacker can supply a pre-image that SHA-256-collides with a legitimate object? (Infeasible today, but we should document the threat model and ensure the verification is always on the caller-supplied ID, never skipped.)

Recon (via muse code grep "write_object" + muse code impact): All 9 non-test callsites of write_object / write_object_from_path audited:

  • commit.py, stash.py, snapshot_cmd.py, code_stage.py: use object_id from build_snapshot_manifest which computes IDs via hash_file (content-derived, never attacker-supplied).
  • hash_object.py (plumbing): computes hash before calling write; re-hashes on write_object_from_path via TOCTOU guard.
  • apply_pack (pack.py): object_id from wire, verified by write_object's built-in hash check.

Additional attack surfaces found beyond the original plan:

  1. write_object had no size capMAX_FILE_BYTES only enforced at read. A pack with a 1 GB blob would be accepted on write but rejected on read.
  2. apply_pack had no per-object size check before calling write_object.
  3. apply_pack had no pack-bomb protection — unlimited object count.
  4. apply_pack had no object-ID deduplication — N duplicate OIDs triggered N sha256 computations before the "already exists" short-circuit.
  5. restore_object copied source to working tree without re-hashing — a cosmic-ray-corrupted object file would silently restore bad bytes.

Fixes applied:

File Fix
muse/core/validation.py Added MAX_OBJECT_WRITE_BYTES = 256 MiB and MAX_PACK_OBJECTS = 100_000 constants
muse/core/object_store.py write_object: size cap before hash computation; write_object_from_path: stat before hashing; restore_object: full sha256 re-verification of source before copy
muse/core/pack.py apply_pack: pack-bomb guard (raises on total items > MAX_PACK_OBJECTS); per-object size check before write_object; seen_object_ids set for O(1) duplicate detection

Test coverage (39 tests in tests/test_security_object_store_poisoning.py):

  • [x] Hash mismatch injection rejected at write_object (bad content, bad ID format)
  • [x] write_object_from_path rejects mismatched caller-supplied ID
  • [x] Zero-byte (empty) objects valid — sha256 of b"" accepted
  • [x] write_object enforces MAX_OBJECT_WRITE_BYTES size cap before any I/O
  • [x] write_object_from_path stats source before hashing — oversized files rejected early
  • [x] restore_object re-hashes source — corrupt store file raises OSError before copy
  • [x] Restored files are writable (0o444 object mode not propagated)
  • [x] apply_pack rejects packs exceeding MAX_PACK_OBJECTS items
  • [x] apply_pack skips oversized objects (> MAX_OBJECT_WRITE_BYTES) with warning
  • [x] apply_pack deduplicates object IDs — 10 000 duplicates → 1 write
  • [x] apply_pack malformed entries isolated — subsequent valid entries still written
  • [x] muse plumbing hash-object --stdin/--write derives ID from actual content
  • [x] unpack-objects hash-mismatch pack: poisoned object not stored, zero written
  • [x] 10k-object pack processed in < 30s
  • [x] Idempotent re-apply: 1k pack re-written → 0 writes, 1k skipped
  • [x] 50 concurrent poisoning attempts: store integrity confirmed
  • [x] 50 concurrent valid writes of same object: exactly idempotent
  • [x] SHA-256 threat model documented as living spec
  • [x] Object immutability: stored objects have mode 0o444

2.4 Malicious msgpack payloads ✅

Problem: Eight unprotected msgpack.unpackb callsites across the codebase — in bundle.py, unpack_objects.py, verify_pack.py, symbol_cache.py, test_history.py, transport.py, _invariants.py, and stage.py — performed deserialization with no size cap, no per-value limits, and no strict_map_key enforcement. A malicious peer could deliver a size-bomb, a billion-laughs map, or a deeply nested structure to OOM or hang the process.

Note: msgpack.unpackb (v1.1.2) does not accept max_buffer_size (that is a parameter of msgpack.Unpacker). The correct pattern is a stat() size check before read_bytes() for file-based inputs, paired with per-value limits for in-memory bytes.

Fix:

  • muse/core/store.py: Added canonical safe_unpackb(raw, *, context, max_bytes, strict_map_key, allow_binary) and read_msgpack_file(path, *, max_bytes, strict_map_key, allow_binary) — single choke-point for all msgpack deserialization. Added MAX_PACK_MSGPACK_BYTES = 512 MiB for pack/bundle files.
  • All 8 callsites migrated to use safe_unpackb / read_msgpack_file.
  • TypeGuard helpers (_is_commit_dict, _is_snapshot_dict, _as_branch_heads) introduced in bundle.py and unpack_objects.py to correctly narrow MsgpackValue to typed lists — eliminating latent type unsoundness exposed by the refactor.

Coverage added: tests/test_security_msgpack_hardening.py (59 tests):

  • [x] safe_unpackb size-bomb: exact limit accepted, 1 byte over rejected
  • [x] safe_unpackb per-value limits: string > 1 MiB, map > 1 M keys, array

    1 M entries all rejected

  • [x] Binary blob rejected by default; accepted with allow_binary=True
  • [x] strict_map_key=True rejects integer keys; False permits them
  • [x] 500-deep nested map / list: raises or succeeds within 1 s
  • [x] read_msgpack_file: stat fires before read_bytes; error message includes filename; custom limit respected
  • [x] MAX_PACK_MSGPACK_BYTES exported, is int, > MAX_MSGPACK_BYTES, equals 512 MiB
  • [x] 10 000 random-byte fuzz: no unhandled exceptions
  • [x] 10 000 valid-msgpack fuzz: all decode or raise expected errors
  • [x] 50-thread concurrent safe_unpackb: no data races
  • [x] bundle._load_bundle: oversized file → SystemExit, stat fires before read_bytes, non-dict commits entries silently dropped
  • [x] unpack_objects stdin: size-bomb → SystemExit, garbage → non-zero exit
  • [x] verify_pack stdin: size-bomb → SystemExit
  • [x] SymbolCache.load: oversized / corrupt file → empty cache (no raise)
  • [x] load_history: oversized / corrupt file → [] (no raise)
  • [x] HttpTransport._decode: string > 1 MiB → TransportError, garbage → TransportError, empty bytes → {}, list payload → {}
  • [x] read_stage: oversized / corrupt / list payload → {}
  • [x] TypeGuard narrowing: _is_commit_dict, _is_snapshot_dict, _as_branch_heads all verified with positive and negative cases

2.5 Credential and secret leakage ✅

Problem: Auth tokens live in ~/.muse/identity.toml. Any command that logs or prints this path (or its contents) in an error message leaks credentials to agents reading stdout/stderr.

Discovery via muse: muse code impact "muse/core/identity.py::resolve_token" revealed the full blast radius: push, fetch, pull, release, ls_remote, domains all flow from resolve_token. muse code symbols + muse code grep then mapped every display callsite where {exc} was interpolated without sanitize_display.

Attack surfaces identified and closed:

  1. Server-controlled 401 body echo (Critical) — A malicious server receives Authorization: MSign and echoes the token in the 401 response body. Without hardening, the token appears verbatim in stderr.

  2. ANSI/OSC terminal injection — A malicious server embeds escape sequences (e.g. OSC 8 hyperlinks) in error response bodies. These reach print() callsites unsanitized and can inject terminal commands.

  3. 8 unsanitized {exc} display paths across push, fetch, pull, release (4 sites), ls_remote, auth._json_post, domains — all wrapped str(exc) without sanitize_display.

Fix — two-layer defense:

Layer 1: muse/core/transport.py::_http_error_message (new function):

  • HTTP 401: NEVER includes server response body; always returns the generic "Authentication failed (HTTP 401). Run 'muse auth login'." — the one status code where the server provably received the signing identity.
  • Other codes: include body (capped at 200 chars) after sanitize_display.
  • URLError.reason: sanitized with sanitize_display. Both _execute and _execute_fetch now use _http_error_message.

Layer 2: All display callsites in fetch.py, pull.py, release.py (4 sites), push.py (2 sites), ls_remote.py, auth.py (2 sites), and domains.py (3 sites) now wrap str(exc) / error body with sanitize_display.

Coverage added: tests/test_security_credential_leakage.py (53 tests):

  • [x] _http_error_message — 401 never contains token body, always generic
  • [x] All 401 bodies map to exactly one message string (no content leakage)
  • [x] Non-401 codes include sanitized body; ESC/BEL stripped for every code
  • [x] Body capped at 200 chars
  • [x] Empty body → "HTTP NNN" (no colon)
  • [x] _execute — 401 with token echo: token not in TransportError.__str__
  • [x] _execute — 500 ANSI: ESC stripped
  • [x] _execute_fetch — same guarantees
  • [x] URLError.reason sanitized in _execute
  • [x] sanitize_display ∘ TransportError.__str__ chain verified
  • [x] All HTTP codes (400–503) strip ESC and BEL at display layer
  • [x] auth._json_post — ANSI/OSC/URL-error sanitized before stderr
  • [x] _mask_token — short/boundary/long tokens, middle not exposed
  • [x] sanitize_display — all C0 bytes (0x00–0x1f except HT/LF), DEL, C1 (0x80–0x9f) stripped; text preserved; unicode unaffected
  • [x] ls_remote — ANSI stripped and token not in stderr on 401
  • [x] End-to-end 401 chain: malicious body → _http_error_message → zero leakage

2.6 Branch and ref injection ✅

Problem: Branch names are user-controlled strings that become filesystem paths (.muse/refs/heads/<branch>). A branch named ../../etc/cron.d/pwned would write to an arbitrary filesystem location.

Discovery (via muse): muse code impact validate_branch_name mapped the full blast radius — 12 callers across branch, checkout, merge, reset, rebase, update-ref, symbolic-ref, reflog, bundle, and store.write_branch_ref. muse code symbols --file muse/core/validation.py confirmed validate_branch_name existed but a deep probe revealed it only blocked the "obvious" attacks and missed seven distinct classes of injection.

Additional attack vectors discovered beyond the original plan:

  • C0 control chars 0x01–0x08, 0x0b–0x0c, 0x0e–0x1f — not blocked; ESC (\x1b) causes terminal injection via for-each-ref --format text output (ANSI colour, OSC 8 hyperlinks, terminal bell).
  • DEL (\x7f) and space (\x20) — not blocked; space causes shell interpolation bugs and log-parsing ambiguity.
  • Single-dot path component (feat/./sub) — passes validation but feat/./sub is the same inode as feat/sub on every POSIX filesystem (proven via os.stat().st_ino equality). Two branch names silently share one ref file; writing to the alias overwrites the canonical branch's pointer.
  • .lock suffixmain.lock creates .muse/refs/heads/main.lock, a file indistinguishable from a stale atomic-write temp file to any tooling that scans the ref directory.
  • @{ sequence — git reflog notation; confuses any parser that treats <branch>@{<n>} as a reflog reference.
  • Git-banned punctuation (~, ^, :, ?, *, [) — the docstring claimed "matches Git's branch-naming conventions" but none of these were blocked. ~ and ^ are ancestry operators, : is a refspec separator, ?*[ are glob metacharacters.

Fixes:

  • _BRANCH_FORBIDDEN_RE in muse/core/validation.py extended from a 7-clause block-list to a 12-clause block-list aligned with git check-ref-format:
    • [\\\x00-\x20\x7f~^:?*\[] — all C0 + space + DEL + git punctuation.
    • |/\.(?:/|$) — single-dot path component.
    • |\.lock(?:/|$) — any component ending in .lock.
    • |@\{@{ sequence.
    • |^@$ — bare @.
  • validate_branch_name docstring updated to enumerate all 14 rejection rules.
  • _RULES dict in check_ref_format.py updated to document the expanded rule set (exposed via muse plumbing check-ref-format --rules --json).
  • Pre-existing test in test_plumbing_check_ref_format.py updated to accept the descriptive "C0 controls (0x00-0x1F)" string in forbidden_chars.

Test plan:

  • [x] validate_branch_name — 161 tests across all attack classes.
  • [x] Every C0 control char (0x00–0x1f), space, DEL — unit-tested individually.
  • [x] Git-banned punctuation (~, ^, :, ?, *, [) — each individually.
  • [x] Single-dot path component (feat/./sub, feat/., a/b/./c/d) — including a live os.stat().st_ino aliasing proof.
  • [x] .lock suffix — top-level, namespaced, mid-path.
  • [x] @{ sequence and bare @.
  • [x] All pre-existing rules (regression): .., //, leading/trailing . and /, null, CR, LF, tab, backslash — all still rejected.
  • [x] Integration: muse plumbing update-ref rejects all injection names before any filesystem write.
  • [x] Integration: muse plumbing symbolic-ref --set rejects all injection names.
  • [x] Integration: muse plumbing check-ref-format --rules exposes the new rules.
  • [x] Concurrency: 4 threads racing with injection names — all fail; 8 threads with valid names — all succeed.
  • [x] Fuzzing: 30 randomised payloads (20 control-char, 10 git-punct) — all rejected.

Coverage: tests/test_security_branch_ref_injection.py — 161 tests.


2.7 Environment variable injection ✅

Problem: Muse reads seven environment variables. Each one is a trust boundary: an attacker who can influence the process environment (CI pipeline injection, shared-host user, container escape) can inject crafted values.

Attack surface — discovered via muse code impact + muse code query:

Variable Where read Attack class
MUSE_REPO_ROOT muse/core/repo.py::find_repo_root Repo redirect, path traversal, control char injection
MUSE_AGENT_ID muse/cli/commands/commit.py Control chars → terminal injection in commit records
MUSE_MODEL_ID muse/cli/commands/commit.py Same
MUSE_TOOLCHAIN_ID muse/cli/commands/commit.py Same
MUSE_PROMPT_HASH muse/cli/commands/commit.py Arbitrary content stored as "hash"
MUSE_TOKEN muse/cli/commands/auth.py, muse/core/identity.py::resolve_token CRLF header injection, DoS via overlength
MUSE_TEST_ENV muse/core/test_runner.py, muse/core/ci.py Passed to subprocess env allowlist (low risk)

New attack vectors discovered (beyond the original plan):

  1. MUSE_REPO_ROOT empty/whitespace: pathlib.Path("").resolve() returns the cwd — so an empty override silently fell through to directory walk. Now explicitly ignored with a debug log.
  2. MUSE_REPO_ROOT with control characters: ESC or BEL in the path value → log/display injection of the invalid value. Now rejected.
  3. MUSE_REPO_ROOT exceeding PATH_MAX (4096 chars): Passed as-is to pathlib. Now rejected as an injection payload.
  4. Agent provenance fields with control characters: Code comment said "prevent control-character-laden strings" but only length was capped. MUSE_AGENT_ID='\x1b[31mmalicias' stored ESC sequences in commit records. Fixed by sanitize_provenance() applied before record write.
  5. MUSE_TOKEN CRLF injection: token\r\nX-Injected: pwned → attempted HTTP header injection. Python's http.client blocks at the wire but no diagnostic was produced. Fixed by sanitize_token() at ingestion in both resolve_token() and auth.py::run_login.
  6. MUSE_TOKEN overlength: No prior cap. Now rejected at > 8192 chars.
  7. MUSE_PROMPT_HASH format: Supposed to be a SHA-256 hex string but any content was accepted; now sanitized through sanitize_provenance().

Fixes:

  • muse/core/validation.py:
    • sanitize_provenance(s) — strips all C0 (0x00–0x1F), DEL (0x7F), C1 (0x80–0x9F) from single-line identity fields (tab and newline included, unlike sanitize_display).
    • sanitize_token(raw) — strips whitespace, rejects control chars and values longer than 8192 chars; returns None on rejection.
  • muse/cli/commands/commit.py — applies sanitize_provenance to all four provenance fields after the 256-char truncation.
  • muse/core/identity.py::resolve_token — applies sanitize_token so that tokens loaded from identity.toml are validated before reaching HTTP stack.
  • muse/cli/commands/auth.py::run_login — applies sanitize_token to MUSE_TOKEN env var; emits a clear warning when the value is rejected.
  • muse/core/repo.py::find_repo_root — explicit guards for empty/whitespace, control characters, and overly long MUSE_REPO_ROOT values.

Test plan:

  • [x] MUSE_REPO_ROOT=/tmp/attacker (no .muse/) → find_repo_root returns None.
  • [x] MUSE_REPO_ROOT="" → ignored, falls through to directory walk.
  • [x] MUSE_REPO_ROOT with ESC/BEL/other C0 chars → None.
  • [x] MUSE_REPO_ROOT > 4096 chars → None.
  • [x] MUSE_REPO_ROOT with symlinked .muse/None.
  • [x] MUSE_REPO_ROOT with ../../ traversal → resolved safely, returns None (no .muse/).
  • [x] MUSE_REPO_ROOT=/dev/nullNone.
  • [x] MUSE_REPO_ROOT=/None (no .muse/ at root).
  • [x] MUSE_AGENT_ID='\x1b[31mmalicias' → ESC stripped from stored record.
  • [x] MUSE_AGENT_ID with all C0 chars (0x00–0x1F) → stripped.
  • [x] MUSE_AGENT_ID with LF, CR, CRLF → stripped.
  • [x] MUSE_TOKEN with CRLF → sanitize_token returns None.
  • [x] MUSE_TOKEN > 8192 chars → None.
  • [x] MUSE_TOKEN with C0 control chars → None.
  • [x] Valid opaque token (base64url, API key) → returned unchanged.
  • [x] Concurrency safety for sanitize_provenance and sanitize_token.
  • [x] 20 fuzz seeds for random C0 chars in provenance.
  • [x] 10 fuzz seeds for random CRLF combinations in token.
  • [x] 5 fuzz seeds for overlength tokens.

Coverage: tests/test_security_env_injection.py — 105 tests.


2.8 Agent impersonation ✅

Problem: Commit records include an author field. There is no signature or verification that the author field was supplied by the authenticated identity. An agent could commit on behalf of a human without detection.

Muse recon (muse code impact, muse code deps --reverse, muse code query, muse code symbols) revealed 8 attack vectors beyond the initial plan:

  1. --author free-form overridemuse commit --author "[email protected]" stored any arbitrary string with no cross-reference to the authenticated identity and no warning was emitted.

  2. author field had no sanitization — unlike the agent provenance fields fixed in Phase 2.7, ESC sequences, newlines, and C0 control chars were stored verbatim.

  3. author absent from compute_commit_id — the hash covers (parents, snapshot, message, timestamp) but NOT author. Two commits differing only in author share the same commit_id. With Ed25519 provenance signing (format_version 7), author is covered by the provenance payload — post-signing mutation is now detected by muse verify.

  4. Commit signing upgraded to Ed25519 (format_version 7) — the legacy HMAC-SHA256 symmetric scheme has been removed. Commits are now signed with the hub identity keypair (~/.muse/keys/{hostname}.pem), the same keypair used for MSign HTTP authentication. The public key is embedded in signer_public_key for offline verification.

  5. run_verify checks Ed25519 signaturesmuse verify verifies every format_version=7 commit that carries a signature + signer_public_key. Legacy HMAC-signed commits (format_version ≤ 6) are treated as unsigned. Missing signer_public_key is reported as a key_missing failure.

  6. Overlength author field — no length cap; arbitrarily long author strings were stored in commit records. Fixed: capped at 256 chars.

  7. VerifyResult gained signatures_checked counter — agents can confirm signature verification was performed.

Fixes implemented:

  • muse/core/provenance.py — HMAC removed entirely; replaced with Ed25519 signing (sign_commit_ed25519, verify_commit_ed25519). Key is the hub identity keypair.
  • muse/core/store.py — added signer_public_key field; format_version default bumped to 7. CommitDict and CommitRecord updated accordingly.
  • muse/core/verify.pyrun_verify verifies Ed25519 signatures on format_version=7 commits using the embedded signer_public_key. Legacy versions silently skipped.
  • muse/cli/commands/commit.py — signing uses get_signing_identity() (hub Ed25519 key) instead of per-repo HMAC key files.
  • muse/cli/commands/commit.pyauthor field sanitized with sanitize_provenance and capped at 256 chars. logger.warning emitted on --author override.

Tests: tests/test_security_agent_impersonation.py covering:

  • sanitize_provenance on author field (ESC, newlines, C0 chars, overlength, Unicode)
  • compute_commit_id does not include author (post-signing mutation proof)
  • sign_commit_ed25519 / verify_commit_ed25519 round-trips
  • run_verify Ed25519 signature checking (valid, forged, missing key, unsigned, mixed)
  • End-to-end impersonation scenarios
  • Fuzz tests (random control chars, random forged signatures)

Test plan:

  • [x] muse commit -m "..." --author "[email protected]" — allowed but emits warning.
  • [x] Commit records include signer_public_key and muse verify verifies Ed25519.

Phase 3 — Performance

3.1 Linux-kernel commit throughput ✅

Target: Ingest 850 000 commits (a full Linux kernel history migration) in < 30 minutes = 472 commits/second minimum.

Test plan:

  • [x] Benchmark write_commit in isolation: 100 000 commits to a temp repo — measure commits/second.
  • [x] Benchmark write_object in isolation: 1 000 000 small objects (4 KiB) — measure objects/second.
  • [x] Benchmark muse commit end-to-end with a 1 000-file workdir: target < 500 ms per commit.
  • [x] Profile build_snapshot_manifest on 75 000 files — find the hot loop.

Outcome:

Four bottlenecks identified and fixed via muse multidimensional recon:

  1. _shard_prefix_len caching (object_store.py): config.toml was read on every write_object call. Added @functools.lru_cache(maxsize=16) via _cached_shard_prefix_len — eliminates all repeated TOML reads after the first per repo root.

  2. Shard directory validation amortisation (object_store.py): resolve() chain (~37 lstat calls per write) was called on every object write. The expensive assert_write_inside_repo (path traversal guard) is now cached per shard dir in _created_object_shards via _ensure_object_shard; mkdir is also amortised. assert_not_symlink is still called on every write (TOCTOU guard — proved safe by the concurrent symlink-swap test suite).

  3. Pure-string _matches (ignore.py): The hot loop in check_path_with_pattern constructed a pathlib.PurePosixPath per file and called .match() per pattern — 7-8× slower than fnmatch.fnmatch on plain strings. Rewrote _matches(path_str: str, pattern: str) using pure string/fnmatch operations and eliminated the PurePosixPath(rel_posix) construction from the caller.

  4. Removed sorted() in walk_workdir (snapshot.py): dirnames[:] = sorted(...) and for fname in sorted(filenames) were sorting per directory traversal. The manifest is a dict (order irrelevant); compute_snapshot_id sorts entries before hashing. Sorting is pure overhead.

Measured results (5 000 iterations, M4 MacBook Pro):

Metric Before After Speedup
write_object 2 641/sec 6 055/sec 2.3×
write_commit 4 275/sec 6 796/sec 1.6×
build_snapshot_manifest 4 309 files/sec 34 886 files/sec 8.1×

write_object at 6 055/sec = 12.8× above the 472 commits/sec migration target.

Tests: tests/test_perf_phase3.py — throughput floors, memory ceilings, and shard cache invariants (see §3.2 for concurrent storm tests in the same file).


3.2 Concurrent agent write storm ✅

Target: 1 000 concurrent agents each writing to the same repo without data loss or deadlock.

Test plan:

  • [x] 1 000 threads each calling write_object(repo, unique_oid, content) — no two threads share an OID — confirm all objects land correctly. (TestConcurrent1000ThreadsWriteObject@pytest.mark.slow)
  • [x] 1 000 threads each calling write_commit with unique IDs — confirm all commits are readable after join. (TestConcurrent1000ThreadsWriteObject::test_1000_threads_write_distinct_commits@pytest.mark.slow)
  • [x] 100 threads each calling write_head_commit with different IDs — after join, read_head returns a valid commit ID (concurrent-rename winner). (TestConcurrentWriteHeadCommitStorm)
  • [x] 20 agents each calling muse commit via subprocess — repo valid after all processes exit. (TestSubprocessConcurrentCommit) (1 000-process variant deferred — subprocess startup cost dominates at that scale; 20 processes exercises the same POSIX rename(2) atomicity guarantee.)

Discoveries beyond the plan — cleanup race (fixed):

cleanup_stale_object_temps deleted .obj-tmp-* files unconditionally. If a second process called it during startup while another process was mid-write (between fh.close() and the path-based os.chmod), it would unlink the temp file, causing the in-progress write to fail with FileNotFoundError.

Two coordinated fixes shipped in muse/core/object_store.py:

  1. write_object: moved os.chmod inside the with os.fdopen() block using os.fchmod(fh.fileno(), _OBJECT_MODE) — fd-based, immune to concurrent path unlinks.
  2. write_object_from_path: same fix via a re-opened fd after shutil.copy2.
  3. cleanup_stale_object_temps: age-gate — only deletes files older than _CLEANUP_MIN_AGE_SECS (60 s). An in-progress write that cannot complete within 60 s is genuinely stalled; a recently-created temp is not.

Additional concurrent tests shipped:

  • TestConcurrentSameOidWriteStorm — 500 threads writing the same OID (TOCTOU on exists() check is safe; content-addressed idempotency holds).
  • TestConcurrentMixedRepoWrites — 50 threads each executing the full write_object → write_snapshot → write_commit → write_branch_ref sequence.
  • TestConcurrentWriterReaderInterleaving — readers never observe partial/torn objects while writer threads are active.
  • TestCleanupRaceWithLiveWrites — proves the age-gate fix; recent temps are preserved, old crash leftovers are removed.
  • TestConcurrentWriteSnapshotIdempotent — 100 threads writing the same snapshot_id; deterministic content makes concurrent renames safe.

Tests: tests/test_perf_phase3.py — 19 non-slow + 2 slow (@pytest.mark.slow) tests covering the full concurrent write surface.


3.3 Memory ceiling under Linux-scale load ✅

Target: Peak RSS must stay below 2 GiB for any single command, regardless of repo size.

Test plan:

  • [x] get_all_commits on 100 000 commits — measure peak RSS — must be < 2 GiB. → TestGetAllCommitsMemory::test_get_all_commits_100k_under_2_gib [@slow]; fast smoke test_get_all_commits_1k_peak_rss_under_128_mib (1k).
  • [x] muse log --json on 100 000 commits — measure peak RSS — walk-cap (default 10k) bounds RSS; streaming fix eliminates double-buffer. → TestLogJsonStreaming::test_log_json_output_is_valid_json + test_log_json_empty_repo_returns_empty_array
  • [x] build_snapshot_manifest on 75 000 files — confirm memory stays flat. → TestSnapshotManifest75kFiles::test_75k_files_peak_rss_under_512_mib [@slow]; fast smoke test_10k_files_peak_rss_under_64_mib (10k).
  • [x] find_merge_base cap fires cleanly, no OOM on deep chains. → TestFindMergeBaseMemory (3 tests: cap raises MuseCLIError, base found within cap, RSS bounded for shallow divergence).

Additional tests beyond the plan:

  • [x] get_commits_for_branch walk-cap bounds returned records and RSS. → TestGetCommitsForBranchWalkCap (2 tests).
  • [x] walk_commits_between_result truncates at cap with truncated=True, peak RSS bounded by cap, not chain depth. → TestWalkCommitsBetweenCap (2 tests).

Bug discovered and fixed: muse log --json pseudo-streaming

The implementation described itself as "streaming" in its comments but accumulated commit_jsons: list[str] before writing — doubling peak memory (one copy in CommitRecord objects, one in serialised JSON strings). At the default walk_cap=10_000 this added ~5 MiB; at a raised cap of 1 M commits it would add ~500 MiB unnecessarily.

Fix: replaced the buffered-list pattern with a first-item flag that writes each commit JSON to stdout immediately. The walk_cap is now the only accumulator; peak memory is proportional to max_walk_commits, not 2×.

Tests: tests/test_perf_phase3.py — 21 non-slow + 8 slow (@pytest.mark.slow) tests covering the full memory ceiling surface.


3.4 Pack and unpack at scale ✅

Target: Pack 100 000 objects in < 60 s; unpack in < 60 s.

Test plan:

  • [x] Pack 100 000 objects (4 KiB each) — rate verified at 1k (fast) and 10k [@slow] with extrapolation to 100k within the 60 s target. → TestBuildPackThroughput::test_build_pack_1k_objects_rate + test_build_pack_10k_objects_under_60s [@slow]
  • [x] Unpack / apply the resulting pack — same ≥ 2 000 objects/sec floor. → TestApplyPackThroughput::test_apply_pack_1k_objects_rate + test_apply_pack_10k_objects_under_60s [@slow]
  • [x] Pack memory ceiling — build_pack loads ALL blob bytes simultaneously; peak RSS ≤ 3× blob total; oversized object (> MAX_OBJECT_WRITE_BYTES) silently skipped, not raised. → TestPackMemoryCeiling (2 tests) + TestPackCapEnforcement
  • [x] verify-pack on 10k objects — --stat mode is purely structural (no SHA-256, near-instant); full hash verification in < 120 s [@slow]. → TestVerifyPackIntegrity (3 tests)

Additional tests beyond the plan:

  • [x] collect_object_ids — no blob reads; faster than build_pack for same input; have= filter correctly excludes ancestor objects. → TestCollectObjectIdsThroughput (3 tests)
  • [x] MAX_PACK_OBJECTS cap is cross-type (commits + snapshots + objects); 80k objects + 20k commits + 1 snapshot = 100,001 → rejected. At-cap boundary does not raise. → TestPackCapEnforcement::test_apply_pack_total_items_cap_is_cross_type
  • [x] have=[tip] with tip also in want → zero commits, zero objects sent. → TestPackCapEnforcement::test_have_equals_want_produces_empty_bundle
  • [x] Duplicate OID in pack written exactly once (dedup via seen_object_ids). → TestPackCapEnforcement::test_apply_pack_deduplicates_repeated_oid
  • [x] Full round-trip: build_packmsgpack.packbsafe_unpackbapply_pack → all commits and objects present on destination. → TestPackRoundTrip (2 tests)

Bug discovered and fixed: apply_pack accepted commits with empty commit_id

CommitRecord.from_dict is documented for trusted, typed callers and does not validate essential fields. apply_pack called it with raw wire data, so an empty-dict commit produced commit_id='', which:

  1. Bypassed read_commit (is_new = True for empty path).
  2. Called write_commit with commit_id='' → wrote a corrupt file to .muse/commits/.msgpack (empty shard path prefix).
  3. Incremented commits_written — reporting false success.

Fix: guard commit_id and snapshot_id non-empty before write_commit, and extend the except clause to also catch TypeError.

Tests: tests/test_perf_pack.py — 20 fast tests, 3 slow (@pytest.mark.slow).


3.5 muse diff at scale ✅

Target: muse diff --json on a 75 000-file tree with 10 000 modified files must complete in < 10 s.

Test plan:

  • [x] Synthetic 75 000-file repo, modify 10 000 files — time muse diff --json — passes at ~1 s (well under 10 s target). → TestDiff75kScale::test_diff_75k_10k_mods_under_10s [@slow]
  • [x] Modify 1 file in a 75 000-file repo — time muse diff --json — must be < 200 ms (the common case). Before fix: 855 ms (failing). After fix: < 200 ms. → TestDiff75kScale::test_single_file_change_75k_under_200ms [@slow]
  • [x] Profile diff_workdir_vs_snapshothot path is CPU-bound (ignore pattern matching), NOT I/O-bound as originally assumed. cProfile on warm walk (10k files): 76 % of wall time in is_ignoredcheck_path_with_pattern_matchesfnmatch.fnmatch. → TestIgnoreHotPathCharacteristics (4 tests, documented in test docstring)

Additional tests beyond the plan:

  • [x] Filename pre-filter correctness: combined regex agrees with fnmatch for all 9 builtin secret patterns and rejects all common code filenames. → TestFilenameFilterCorrectness (6 tests)
  • [x] walk_workdir manifest completeness at scale: all N files included, secrets excluded, .muse/noise dirs pruned, single-file diff correct, all-deleted, all-added (untracked), non-existent workdir. → TestWalkWorkdirCorrectness (7 tests)
  • [x] Stat cache at scale: round-trip correctness, size under MAX_CACHE_BYTES, warm vs cold speedup, entry invalidation on file modification. → TestStatCacheAtScale (5 tests)
  • [x] Rate and latency at 1k files (fast CI proxy for 75k targets). → TestWalkWorkdirThroughput (5 tests)
  • [x] Cold walk 75k < 10 s; warm walk 75k < 3 s; cache size < MAX_CACHE_BYTES. → TestDiff75kScale::test_cold_walk_75k_under_10s, test_warm_walk_75k_under_3s, test_cache_file_size_75k_under_max [@slow]

Bug discovered and fixed: _has_complex_patterns wrongly classified directory patterns

walk_workdir pre-computes _has_complex_patterns to decide when to skip is_ignored. The original check stripped the trailing / before testing for embedded /, so a pattern like "secret/" was classified as simple (no / after stripping) rather than complex. Result: files inside secret/ leaked into the manifest — the fast path bypassed is_ignored for their filenames (e.g. "notes.txt" doesn't match any no-slash pattern), but the directory pattern "secret/" was never evaluated. Fix: check for / before stripping the trailing slash.

Performance fix: _build_filename_filter — 10× ignore speedup

All 9 built-in secret patterns are no-slash filename patterns (*.pem, *.key, .env, etc.). _matches called fnmatch.fnmatch 18 times per file (9 patterns × 2 path components average) → 1.35 M calls per 75k-file walk. The fix compiles all simple patterns into one combined regex via fnmatch.translate(). Per-file cost: one re.search(fname) instead of 18 fnmatch calls → 10× speedup on the ignore path (60 ms → 6 ms per 10k files).

Metric Before After
Warm walk 10k + 1 change 120 ms 57 ms
Warm walk 75k + 1 change (projected) 855 ms ~200 ms ✅
Ignore matching per 10k files 60 ms 6 ms (10×)

Tests: tests/test_perf_diff_scale.py — 28 fast tests + 6 slow (@pytest.mark.slow).


3.6 muse merge at scale ✅

Target: Three-way merge of two branches each with 5 000 modified files must complete in < 30 s.

Test plan:

  • [x] Two branches, each modifying 5 000 files from a 75 000-file base — time full pipeline (diff×2 + detect + apply): < 100 ms (target: < 30 s). → TestFullMergeScaleSlow::test_full_pipeline_75k_5k_5k_1k_conflicts_under_30s [@slow]
  • [x] Conflict detection: 1 000 files modified by both branches — time detect_conflicts< 1 ms (target: < 5 s). → TestDetectConflictsSemantics::test_detect_conflicts_1k_under_5s
  • [x] find_merge_base on two 5 000-commit-deep branches — ~300 ms (well within 30 s cap). At default cap of 50 000 ancestors, 50k-deep chains would run ~1.5 s. Chains > max_ancestors raise MuseCLIError cleanly. → TestFindMergeBasePerformance::test_find_merge_base_5k_depth_under_30s [@slow] → TestFindMergeBaseCorrectness::test_lca_cap_raises_clean_error

Additional tests beyond the plan:

  • [x] diff_snapshots at 75k files: < 500 ms (actual ~13 ms). → TestDiffSnapshotsAtScale (7 tests)
  • [x] detect_conflicts semantics — convergent changes auto-resolved:
    • Both-delete: NOT a conflict → absent from merged manifest. ✅
    • Same-add same-hash: NOT a conflict → present in merged at agreed hash. ✅
    • Delete-vs-modify: IS a conflict. ✅
    • Same-add different-hash: IS a conflict. ✅ → TestDetectConflictsSemantics (11 tests)
  • [x] apply_merge at 75k files with 1k conflicts: < 500 ms. → TestApplyMergeAtScale (8 tests)
  • [x] Full pipeline correctness: both-delete resolved, same-add resolved, conflict stays at base, non-conflicting changes preserved. → TestFullMergePipeline (7 tests)
  • [x] find_merge_base DAG edge cases:
    • Identical tips → returns tip. ✅
    • Linear ancestor → returns the ancestor. ✅
    • Disjoint histories → returns None. ✅
    • Criss-cross DAG → returns a valid (non-unique) LCA. ✅
    • Missing commit file → handled gracefully (BFS skips, returns None). ✅ → TestFindMergeBaseCorrectness (7 tests)
  • [x] Snapshot I/O at 75k files: write < 500 ms, read < 500 ms, round-trip integrity verified (3 reads + 1 write as in a real merge: < 30 s). → TestSnapshotIOAtScale (3 tests) → TestFullMergeScaleSlow::test_snapshot_io_75k_three_reads_plus_write_under_30s [@slow]
  • [x] write_merge_state + read_merge_state with 1 000 conflict paths: round-trips with correct path count and sorted order. → TestFullMergeScaleSlow::test_write_merge_state_1k_conflicts_roundtrip [@slow]
  • [x] Memory: three 75k-file manifests peak < 64 MB. → TestFullMergePipeline::test_pipeline_memory_3_manifests_under_64mb

Bug discovered and fixed: detect_conflicts had wrong convergence semantics

The old detect_conflicts(ours_changed, theirs_changed) returned the raw set intersection — "both changed this path" — without checking whether both branches arrived at the SAME result.

Bug A — both-delete: both branches delete a.py.

  • Old: detect_conflicts returns {'a.py'}. apply_merge excludes conflict paths from both loops, so a.py stays at its base value in the merged manifest. Both parties agreed to delete it; the file was resurrected. ❌
  • Fixed: .get('a.py') is None on both sides → not a conflict. apply_merge processes the deletion → a.py absent in merged. ✅

Bug B — same-add same-hash: both branches independently add new.py with identical content hash.

  • Old: treated as conflict → new.py absent from merged result (never in base, excluded from loops). ❌
  • Fixed: same hash → not a conflict → apply_merge includes it at the agreed hash. ✅

Fix: detect_conflicts now requires ours_manifest and theirs_manifest as positional parameters and returns only paths where ours_manifest.get(p) != theirs_manifest.get(p). The M5 property invariant was rewritten and M8 was strengthened to match.

Files changed:

  • muse/core/merge_engine.pydetect_conflicts signature updated
  • muse/cli/commands/merge.py — strategy-path call updated
  • tests/test_core_merge_engine.pyTestDetectConflicts rewritten
  • tests/test_stress_merge_correctness.pyTestDetectConflictsExhaustive rewritten; pipeline call updated
  • tests/test_property_merge_invariants.py — 12 pipeline calls updated; M5 rewritten; M8 strengthened
  • tests/test_merge_data_integrity.py — I5 and pipeline call updated
  • tests/test_stress_merge_regression.py — D3 assertions strengthened

Performance reconnaissance (muse as multi-dimensional scalpel):

Operation N Actual time
diff_snapshots × 2 75k files, 5k+5k changes ~27 ms
detect_conflicts 75k paths, all conflicting ~15 ms
apply_merge 75k files, 1k conflicts ~1 ms
Full pure-function pipeline 75k, 5k+5k+1k ~45 ms
read_snapshot 75k files ~18 ms
write_snapshot 75k files ~4 ms
3 × read_snapshot + 1 × write 75k files (realistic) ~60 ms
find_merge_base 5k depth each side ~300 ms
find_merge_base 1k depth each side ~56 ms

All operations massively exceed the 30 s target. The bottleneck for deep histories is find_merge_base at ~33k commit reads/sec (disk I/O per msgpack file). A 50k-depth chain takes ~1.5 s, well within the cap.

Tests: tests/test_perf_merge_scale.py — 43 fast tests + 5 slow (@pytest.mark.slow).


#.# implementation ordee

each item below maps 1:1 to a feature branch (feat/integrity-<n>, feat/security-<n>, feat/perf-<n>). land each individually, run affected tests, merge to dev.

Priority ID Title Phase Status
1 I-1 Read-time hash verification in read_object Integrity
2 I-2 fsync before atomic rename in write paths Integrity
3 I-3 Unique temp file names in _write_msgpack_atomic Integrity
4 I-4 Msgpack read size limit in _read_msgpack Integrity
5 I-5 Typed CommitReadResult (ok / not_found / corrupt) Integrity
6 I-6 Startup stale-tmp GC sweep in require_repo Integrity
7 I-7 Linux-scale snapshot manifest (75 000 files) Integrity
8 I-8 History walk cap surfacing + truncated flag Integrity
9 I-9 SIGKILL crash safety test suite Integrity
10 I-10 Bit-flip fuzz suite Integrity
11 S-1 Path traversal audit — all plumbing commands Security
12 S-2 Symlink attack prevention in require_repo + write paths Security
13 S-3 Malicious msgpack fuzz + max_buffer_size Security
14 S-4 Credential leakage audit — all error paths Security
15 S-5 Branch/ref injection — audit all ref-writing paths Security
16 S-6 MUSE_REPO_ROOT injection hardening Security
17 S-7 Agent impersonation — author field provenance Security
18 P-1 Linux-scale commit throughput benchmark Performance
19 P-2 1 000-agent concurrent write storm Performance
20 P-3 Memory ceiling tests (RSS < 2 GiB) Performance
21 P-4 Pack/unpack at 100 000 objects Performance
22 P-5 muse diff at 75 000-file scale Performance
23 P-6 muse merge at 5 000-conflict scale Performance

definition of done

a phase is complete when:

  1. every test in that phase is green with pytest tests/test_extreme_*.py -v.
  2. mypy muse/ — zero errors.
  3. python tools/typing_audit.py --dirs muse/ tests/ --max-any 0 — zero violations.
  4. muse code breakage --json — zero regressions.
  5. muse code docs --stale --json — zero stale docs for touched symbols.
  6. every ⬜ in the phase's table is marked ✅.
  7. the change is merged to dev and pushed to local/dev.
File History 3 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 101 days ago