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:
- Data Integrity — a single lost or corrupted bit stops adoption on day one.
- Security — agents run in sensitive environments; leakage or escalation is fatal.
- 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
OSErrorand logs atCRITICAL. - [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-packcatches bit-flipped objects in local store (fix: replacedhas_object()existence check withread_object()hash-verified read — corrupt local objects are now reported askind="object"failures). - [x] Benchmark: 256 MiB object re-hash confirmed < 500 ms (passes on tmpfs).
Additional coverage added (gap audit):
- [x]
TestCriticalLogOnCorruption— verifiesCRITICALlog 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 thehas_object→read_objectfix: clean passes, bit-flip fails, zero-fill fails,--no-localcorrectly skips the integrity check. - [x]
TestVerifyObjectAllCorrupt—verify-object --allcatches 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.replaceto raiseOSErrormid-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 beforeos.replace()inwrite_object,_write_msgpack_atomic, andwrite_text_atomic.mkstemp→fsync→renamepattern 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_pathordering, non-fatal failure,shutil.copy2failure orphan cleanup, replace failure orphan cleanup, content round-trip. - [x]
TestRestoreObjectFsync(4 tests) — same pattern forrestore_object. - [x]
TestIdempotentConcurrentWrite(2 tests) — 50 threads writing the sameobject_idsimultaneously; wrong-content write rejected. - [x]
TestMidWriteFailureCleanup(2 tests) —OSErrorduringfh.writeitself (not just atreplace) leaves no orphan inwrite_objectorwrite_text_atomic.
Fix: write_object: mkstemp → fdopen → flush → _fsync_fd →
os.replace. _write_msgpack_atomic and write_text_atomic: same
mkstemp → flush → fsync → tmp.replace pattern. write_object_from_path
and restore_object: mkstemp → shutil.copy2 → _fsync_path → os.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-
.tmpapproach 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
mkstempcalls produce 50 distinct names. Proves the OS guarantee that underpins the entire fix. - [x] 50 threads
write_head_commit— final HEAD is always a validcommit: <64-hex>string; no torn prefix ever observed. - [x] 50 threads
write_head_commitreader — continuous reader thread sees only valid HEAD values during 50 concurrent writes. - [x] 100 threads same branch ref —
write_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+ 25write_head_committhreads; HEAD always valid after every interleaved write. - [x] Amplified race window
write_text_atomic— 100 threads with 1ms sleep injected beforeos.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_tagcalls — 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+ 100write_head_branchconcurrent writes. - [x] Reader + concurrent
write_commit— reader checking commit records always sees complete, correct records. - [x]
write_text_atomic100-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_msgpackcheckspath.stat().st_sizebeforeread_bytes()— no allocation occurs on oversized files. Per-value limits onmsgpack.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 withmax_bin_len=1 MiB(indices may store binary body hashes).
Test plan (all complete):
- [x] Constant export —
MAX_MSGPACK_BYTESimportable, 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, trackedread_bytescall — not called). - [x]
read_commitreturnsNonefor oversized file — not OOM, not crash. - [x]
read_snapshotreturnsNonefor oversized file. - [x]
get_all_tagsskips oversized tag files, preserves good tags. - [x]
list_releasesskips oversized release files. - [x] Exact boundary: file at limit passes (parse error, not size error).
- [x] One byte over limit raises
OSErrorwith 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_lenenforced — string > 1 MiB rejected by unpackb. - [x]
max_bin_len=0enforced — binary blob in store record raises on unpack. - [x]
max_map_lenenforced — oversized map raises on unpack. - [x]
max_array_lenenforced — 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_historyskips oversized index — returns empty dict. - [x]
load_hash_occurrenceskips 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-commitwith 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:
- File exists + parseable +
commit_idmatches → normal skip (unchanged). - File exists + corrupt → CRITICAL log + overwrite with incoming good record.
- File exists + parseable but
commit_idmismatch →OSError("Store integrity violation")— tamper detection.
read_commit / read_snapshot / read_release — logger.warning → logger.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,CommitReadCorruptTypedDicts.read_commit_result()— typed discriminated union:"ok"/"not_found"/"corrupt".commit_read_is_ok/not_found/corrupt()—TypeGuardnarrowing functions (zerotype: ignore).SnapshotReadOk,SnapshotReadNotFound,SnapshotReadCorruptTypedDicts.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_idmismatch in stored record →OSErrorraised. - [x]
read_commitlogs at levelCRITICAL(50) — neverWARNING(30). - [x]
read_commit_resultreturns all three statuses with correct key contracts. - [x]
TypeGuardfunctions provide zero-type:ignorenarrowing. - [x]
read_snapshot/read_snapshot_result— full parity. - [x]
get_all_commits/get_all_tags/list_releases— CRITICAL on corrupt. - [x]
muse plumbing read-commitCLI 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.jsonreplaced bystat_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_inoadded 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 andmuse checkout) served a stale hash. New inode → definite miss.mkstempinsave(): Eliminates the fixed.tmpsuffix race where two concurrentmuse commitprocesses clobbered each other's temp file.fsyncinsave(): 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 passst.st_ino.
Test coverage (tests/test_integrity_I6_snapshot_scale.py — 27 tests):
- ✅
TestCacheFormat(5) — msgpack v2 on disk,inofield 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_BYTESexported, 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:
- O(n²) BFS bug:
_collect_all_commitsusedlist.pop(0)— O(n) per pop, making the full graph BFS O(n²). Fixed todeque.popleft()— O(1). _collect_all_commitsunbounded:--graph --allhad no safety cap. Addedmax_graph_commits(configurable, default 50k) with warning banner.find_merge_baseB-side silent truncation: A-side raisedMuseCLIError, B-side returnedNonesilently — inconsistent. Both now raise the same error.muse log --jsonbatch output: entire history buffered before emit. Now streams one commit at a time — zero extra memory per additional commit.commit_graphtext/count-only formats missingtruncated: JSON had it; text and count-only did not. Fixed for all three output formats.- All caps hardcoded: now read from
[limits]section in config.toml viaget_limit(). Keys:max_walk_commits,max_ancestors,max_graph_commits.
New types / functions:
WalkResult(TypedDict)instore.py—{commits, truncated, count}walk_commits_between_result()— same walk, returnsWalkResultLimitsConfig(TypedDict)inconfig.pyget_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):
WalkResultTypedDict 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_baseraises, 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_commitstuple return shape, truncation at cap- 10k BFS completes in < 5s (O(n) proof) —
@slow commit-graph:truncated=truein JSON, text (# TRUNCATED), count-only- 15k
commit-graphcompletes in < 30s —@slow muse log --json: hastruncatedfield, hascommitsarray, correct fieldsfrom_commit_idexclusion 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:
0o444mode bug fixed (confirmed: existing objects were0o600).write_objectandwrite_object_from_pathnow callos.chmod(tmp, 0o444)beforeos.replace, so every object lands read-only atomically. Directopen(path, 'wb')now raisesPermissionError— the OS enforces content-addressability.Configurable 4-char sharding added:
[limits] shard_prefix_length = 4in.muse/config.tomlswitches 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:
0o600mode on all existing objects (confirmed via live store inspection).- Both write paths (
write_object+write_object_from_path) lacked chmod. - No stale temp cleanup anywhere in the codebase.
object_path()had noprefix_lenparam — callers couldn't opt into 4-char.- 29 corruption tests in I1/I2 broke immediately (proof the mode guard works)
— fixed with
_corrupt_filehelpers that temporarily lift0o444.
has_objectO(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) —0o444on both write paths, constant value, round-trip reads, direct overwrite raisesPermissionError, batch verify.TestStaleTempCleanup(6 tests) — removes.obj-tmp-*and.restore-tmp-*, preserves real objects, handles missing store, idempotent, multi-shard.TestHasObjectPerformance(3 tests) —< 10 msat 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 confirmed0o444.
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)inmuse/core/repo.py— sweeps all three stale-temp families on everyrequire_repo()call (184 callers = every command):.obj-tmp-*/.restore-tmp-*— object-store shard directories (delegated to the existingcleanup_stale_object_temps).muse-tmp-*— created bywrite_text_atomicand_write_msgpack_atomicin.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 byStatCache.savein.muse/
_cleanup_muse_dir_temps(muse_dir)— new helper that iterates the known_MUSE_SWEEP_DIRSset (never touchesobjects/, which is handled separately) and unlinks any file whose name starts with a prefix in_MUSE_TEMP_PREFIXES.objects_dirdocstring corrected — the old docstring falsely claimed cleanup happens on first access; the actual cleanup now happens inrequire_repoand 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_objectis 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_DIRSconstants - ✅
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.msgpackfiles preserved - ✅
TestStartupGcStatCacheTemps(3) —.stat_cache_*.tmpswept; realstat_cache.msgpackpreserved; multiple tmps all swept - ✅
TestRequireRepoCallsGc(5) —require_repotriggers 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 andwrite_text_atomicloops; startup GC clears all orphans; pre-kill objects readable - ✅
TestSigkillDuringCommit(2,@slow) — full CLImuse commitSIGKILL;muse statusexits 0; no stale temps after next command - ✅
TestSigkillDuringPush(3,@slow) — push SIGKILL leaves no corruption on remote;write_objectidempotency 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_gcwith 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
.msgpackfile: flip every bit and confirm eithermsgpack.UnpackExceptionis 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-prefix—validate_path_prefixgates the prefix arg. - ✅
check-attr— replaced ad-hoc null-byte check withvalidate_workspace_path; fixed CRLF stdin (rstrip("\\r\\n")). - ✅
check-ignore— same as check-attr. - ✅
verify-object --stdin— CRLF fixed:rstrip("\\r\\n"). - ✅
apply_pack— zip-slip closed: all manifest keys validated withvalidate_workspace_pathand all manifest values validated withvalidate_object_idbefore any snapshot is written to the store. - ✅
apply_pack— malicious object IDs now caught:ValueErrorfromwrite_objectis 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-prefixrejected. - ✅ 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).
2.2 Symlink attacks ✅
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:
.muse/itself symlinked —find_repo_rootcalledis_dir()which follows symlinks; entire repo would be redirected..muse/objects/(or shard dirs) symlinked —write_objectcalleddest.parent.mkdir()thenmkstemp(dir=dest.parent), writing into the symlink target without any check..muse/commits/,.muse/snapshots/, etc. symlinked — same TOCTOU window in_write_msgpack_atomicandwrite_text_atomic.- Cleanup functions entering symlinked dirs —
cleanup_stale_object_tempsand_cleanup_muse_dir_tempsiterated shard/subdir entries without checking for symlinks, potentially deleting files in attacker-controlled locations. - Tracked-file symlinks — already safe (
os.lstat + S_ISREGfilter inbuild_snapshot_manifestsilently 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/HEADis a symlink →write_text_atomicreplaces symlink entry (POSIX-safe, documented) - [x]
.muse/objects/symlinked →write_objectraises before any filesystem modification - [x]
.muse/objects/<shard>/symlinked →write_objectraises, attacker dir stays empty - [x]
.muse/commits/symlinked →_write_msgpack_atomicraises, attacker dir stays empty - [x] All 6 critical subdirs (
objects,commits,snapshots,refs,refs/heads,tags) →_verify_muse_dir_integrityexits on any symlink - [x]
find_repo_rootrejects.muse/symlinks in both normal walk andMUSE_REPO_ROOTenv 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_repocatches resolved-path escape - [x] 50-thread concurrent symlink-swap stress: attacker dir stays empty throughout
- [x] E2E:
muse statusrejects 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: useobject_idfrombuild_snapshot_manifestwhich computes IDs viahash_file(content-derived, never attacker-supplied).hash_object.py(plumbing): computes hash before calling write; re-hashes onwrite_object_from_pathvia TOCTOU guard.apply_pack(pack.py):object_idfrom wire, verified bywrite_object's built-in hash check.
Additional attack surfaces found beyond the original plan:
write_objecthad no size cap —MAX_FILE_BYTESonly enforced at read. A pack with a 1 GB blob would be accepted on write but rejected on read.apply_packhad no per-object size check before callingwrite_object.apply_packhad no pack-bomb protection — unlimited object count.apply_packhad no object-ID deduplication — N duplicate OIDs triggered N sha256 computations before the "already exists" short-circuit.restore_objectcopied 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_pathrejects mismatched caller-supplied ID - [x] Zero-byte (empty) objects valid — sha256 of
b""accepted - [x]
write_objectenforcesMAX_OBJECT_WRITE_BYTESsize cap before any I/O - [x]
write_object_from_pathstats source before hashing — oversized files rejected early - [x]
restore_objectre-hashes source — corrupt store file raisesOSErrorbefore copy - [x] Restored files are writable (0o444 object mode not propagated)
- [x]
apply_packrejects packs exceedingMAX_PACK_OBJECTSitems - [x]
apply_packskips oversized objects (>MAX_OBJECT_WRITE_BYTES) with warning - [x]
apply_packdeduplicates object IDs — 10 000 duplicates → 1 write - [x]
apply_packmalformed entries isolated — subsequent valid entries still written - [x]
muse plumbing hash-object --stdin/--writederives ID from actual content - [x]
unpack-objectshash-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 canonicalsafe_unpackb(raw, *, context, max_bytes, strict_map_key, allow_binary)andread_msgpack_file(path, *, max_bytes, strict_map_key, allow_binary)— single choke-point for all msgpack deserialization. AddedMAX_PACK_MSGPACK_BYTES = 512 MiBfor pack/bundle files.- All 8 callsites migrated to use
safe_unpackb/read_msgpack_file. TypeGuardhelpers (_is_commit_dict,_is_snapshot_dict,_as_branch_heads) introduced inbundle.pyandunpack_objects.pyto correctly narrowMsgpackValueto typed lists — eliminating latent type unsoundness exposed by the refactor.
Coverage added: tests/test_security_msgpack_hardening.py (59 tests):
- [x]
safe_unpackbsize-bomb: exact limit accepted, 1 byte over rejected - [x]
safe_unpackbper-value limits: string > 1 MiB, map > 1 M keys, array1 M entries all rejected
- [x] Binary blob rejected by default; accepted with
allow_binary=True - [x]
strict_map_key=Truerejects integer keys;Falsepermits them - [x] 500-deep nested map / list: raises or succeeds within 1 s
- [x]
read_msgpack_file: stat fires beforeread_bytes; error message includes filename; custom limit respected - [x]
MAX_PACK_MSGPACK_BYTESexported, isint, >MAX_MSGPACK_BYTES, equals512 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 beforeread_bytes, non-dict commits entries silently dropped - [x]
unpack_objectsstdin: size-bomb →SystemExit, garbage → non-zero exit - [x]
verify_packstdin: 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]
TypeGuardnarrowing:_is_commit_dict,_is_snapshot_dict,_as_branch_headsall 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:
Server-controlled 401 body echo (Critical) — A malicious server receives
Authorization: MSignand echoes the token in the 401 response body. Without hardening, the token appears verbatim in stderr.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.8 unsanitized
{exc}display paths acrosspush,fetch,pull,release(4 sites),ls_remote,auth._json_post,domains— all wrappedstr(exc)withoutsanitize_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 withsanitize_display. Both_executeand_execute_fetchnow 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 inTransportError.__str__ - [x]
_execute— 500 ANSI: ESC stripped - [x]
_execute_fetch— same guarantees - [x]
URLError.reasonsanitized 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 viafor-each-ref --format textoutput (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 butfeat/./subis the same inode asfeat/subon every POSIX filesystem (proven viaos.stat().st_inoequality). Two branch names silently share one ref file; writing to the alias overwrites the canonical branch's pointer. .locksuffix —main.lockcreates.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_REinmuse/core/validation.pyextended from a 7-clause block-list to a 12-clause block-list aligned withgit 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_namedocstring updated to enumerate all 14 rejection rules._RULESdict incheck_ref_format.pyupdated to document the expanded rule set (exposed viamuse plumbing check-ref-format --rules --json).- Pre-existing test in
test_plumbing_check_ref_format.pyupdated to accept the descriptive"C0 controls (0x00-0x1F)"string inforbidden_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 liveos.stat().st_inoaliasing proof. - [x]
.locksuffix — 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-refrejects all injection names before any filesystem write. - [x] Integration:
muse plumbing symbolic-ref --setrejects all injection names. - [x] Integration:
muse plumbing check-ref-format --rulesexposes 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):
MUSE_REPO_ROOTempty/whitespace:pathlib.Path("").resolve()returns the cwd — so an empty override silently fell through to directory walk. Now explicitly ignored with a debug log.MUSE_REPO_ROOTwith control characters: ESC or BEL in the path value → log/display injection of the invalid value. Now rejected.MUSE_REPO_ROOTexceeding PATH_MAX (4096 chars): Passed as-is topathlib. Now rejected as an injection payload.- 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 bysanitize_provenance()applied before record write. MUSE_TOKENCRLF injection:token\r\nX-Injected: pwned→ attempted HTTP header injection. Python'shttp.clientblocks at the wire but no diagnostic was produced. Fixed bysanitize_token()at ingestion in bothresolve_token()andauth.py::run_login.MUSE_TOKENoverlength: No prior cap. Now rejected at > 8192 chars.MUSE_PROMPT_HASHformat: Supposed to be a SHA-256 hex string but any content was accepted; now sanitized throughsanitize_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, unlikesanitize_display).sanitize_token(raw)— strips whitespace, rejects control chars and values longer than 8192 chars; returnsNoneon rejection.
muse/cli/commands/commit.py— appliessanitize_provenanceto all four provenance fields after the 256-char truncation.muse/core/identity.py::resolve_token— appliessanitize_tokenso that tokens loaded fromidentity.tomlare validated before reaching HTTP stack.muse/cli/commands/auth.py::run_login— appliessanitize_tokentoMUSE_TOKENenv 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 longMUSE_REPO_ROOTvalues.
Test plan:
- [x]
MUSE_REPO_ROOT=/tmp/attacker(no.muse/) →find_repo_rootreturnsNone. - [x]
MUSE_REPO_ROOT=""→ ignored, falls through to directory walk. - [x]
MUSE_REPO_ROOTwith ESC/BEL/other C0 chars →None. - [x]
MUSE_REPO_ROOT> 4096 chars →None. - [x]
MUSE_REPO_ROOTwith symlinked.muse/→None. - [x]
MUSE_REPO_ROOTwith../../traversal → resolved safely, returnsNone(no.muse/). - [x]
MUSE_REPO_ROOT=/dev/null→None. - [x]
MUSE_REPO_ROOT=/→None(no.muse/at root). - [x]
MUSE_AGENT_ID='\x1b[31mmalicias'→ ESC stripped from stored record. - [x]
MUSE_AGENT_IDwith all C0 chars (0x00–0x1F) → stripped. - [x]
MUSE_AGENT_IDwith LF, CR, CRLF → stripped. - [x]
MUSE_TOKENwith CRLF →sanitize_tokenreturnsNone. - [x]
MUSE_TOKEN> 8192 chars →None. - [x]
MUSE_TOKENwith C0 control chars →None. - [x] Valid opaque token (base64url, API key) → returned unchanged.
- [x] Concurrency safety for
sanitize_provenanceandsanitize_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:
--authorfree-form override —muse commit --author "[email protected]"stored any arbitrary string with no cross-reference to the authenticated identity and no warning was emitted.authorfield had no sanitization — unlike the agent provenance fields fixed in Phase 2.7, ESC sequences, newlines, and C0 control chars were stored verbatim.authorabsent fromcompute_commit_id— the hash covers(parents, snapshot, message, timestamp)but NOTauthor. Two commits differing only in author share the samecommit_id. With Ed25519 provenance signing (format_version 7),authoris covered by the provenance payload — post-signing mutation is now detected bymuse verify.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 insigner_public_keyfor offline verification.run_verifychecks Ed25519 signatures —muse verifyverifies every format_version=7 commit that carries asignature+signer_public_key. Legacy HMAC-signed commits (format_version ≤ 6) are treated as unsigned. Missingsigner_public_keyis reported as akey_missingfailure.Overlength author field — no length cap; arbitrarily long author strings were stored in commit records. Fixed: capped at 256 chars.
VerifyResultgainedsignatures_checkedcounter — 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— addedsigner_public_keyfield;format_versiondefault bumped to 7.CommitDictandCommitRecordupdated accordingly.muse/core/verify.py—run_verifyverifies Ed25519 signatures on format_version=7 commits using the embeddedsigner_public_key. Legacy versions silently skipped.muse/cli/commands/commit.py— signing usesget_signing_identity()(hub Ed25519 key) instead of per-repo HMAC key files.muse/cli/commands/commit.py—authorfield sanitized withsanitize_provenanceand capped at 256 chars.logger.warningemitted on--authoroverride.
Tests: tests/test_security_agent_impersonation.py covering:
sanitize_provenanceon author field (ESC, newlines, C0 chars, overlength, Unicode)compute_commit_iddoes not include author (post-signing mutation proof)sign_commit_ed25519/verify_commit_ed25519round-tripsrun_verifyEd25519 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_keyandmuse verifyverifies 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_commitin isolation: 100 000 commits to a temp repo — measure commits/second. - [x] Benchmark
write_objectin isolation: 1 000 000 small objects (4 KiB) — measure objects/second. - [x] Benchmark
muse commitend-to-end with a 1 000-file workdir: target < 500 ms per commit. - [x] Profile
build_snapshot_manifeston 75 000 files — find the hot loop.
Outcome:
Four bottlenecks identified and fixed via muse multidimensional recon:
_shard_prefix_lencaching (object_store.py):config.tomlwas read on everywrite_objectcall. Added@functools.lru_cache(maxsize=16)via_cached_shard_prefix_len— eliminates all repeated TOML reads after the first per repo root.Shard directory validation amortisation (
object_store.py):resolve()chain (~37lstatcalls per write) was called on every object write. The expensiveassert_write_inside_repo(path traversal guard) is now cached per shard dir in_created_object_shardsvia_ensure_object_shard;mkdiris also amortised.assert_not_symlinkis still called on every write (TOCTOU guard — proved safe by the concurrent symlink-swap test suite).Pure-string
_matches(ignore.py): The hot loop incheck_path_with_patternconstructed apathlib.PurePosixPathper file and called.match()per pattern — 7-8× slower thanfnmatch.fnmatchon plain strings. Rewrote_matches(path_str: str, pattern: str)using pure string/fnmatch operations and eliminated thePurePosixPath(rel_posix)construction from the caller.Removed
sorted()inwalk_workdir(snapshot.py):dirnames[:] = sorted(...)andfor fname in sorted(filenames)were sorting per directory traversal. The manifest is adict(order irrelevant);compute_snapshot_idsorts 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_commitwith 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_commitwith different IDs — after join,read_headreturns a valid commit ID (concurrent-rename winner). (TestConcurrentWriteHeadCommitStorm) - [x] 20 agents each calling
muse commitviasubprocess— repo valid after all processes exit. (TestSubprocessConcurrentCommit) (1 000-process variant deferred — subprocess startup cost dominates at that scale; 20 processes exercises the same POSIXrename(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:
write_object: movedos.chmodinside thewith os.fdopen()block usingos.fchmod(fh.fileno(), _OBJECT_MODE)— fd-based, immune to concurrent path unlinks.write_object_from_path: same fix via a re-opened fd aftershutil.copy2.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 onexists()check is safe; content-addressed idempotency holds).TestConcurrentMixedRepoWrites— 50 threads each executing the fullwrite_object → write_snapshot → write_commit → write_branch_refsequence.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 samesnapshot_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_commitson 100 000 commits — measure peak RSS — must be < 2 GiB. →TestGetAllCommitsMemory::test_get_all_commits_100k_under_2_gib[@slow]; fast smoketest_get_all_commits_1k_peak_rss_under_128_mib(1k). - [x]
muse log --jsonon 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_manifeston 75 000 files — confirm memory stays flat. →TestSnapshotManifest75kFiles::test_75k_files_peak_rss_under_512_mib[@slow]; fast smoketest_10k_files_peak_rss_under_64_mib(10k). - [x]
find_merge_basecap fires cleanly, no OOM on deep chains. →TestFindMergeBaseMemory(3 tests: cap raisesMuseCLIError, base found within cap, RSS bounded for shallow divergence).
Additional tests beyond the plan:
- [x]
get_commits_for_branchwalk-cap bounds returned records and RSS. →TestGetCommitsForBranchWalkCap(2 tests). - [x]
walk_commits_between_resulttruncates at cap withtruncated=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_packloads 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-packon 10k objects —--statmode 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 thanbuild_packfor same input;have=filter correctly excludes ancestor objects. →TestCollectObjectIdsThroughput(3 tests) - [x]
MAX_PACK_OBJECTScap 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]withtipalso 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_pack→msgpack.packb→safe_unpackb→apply_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:
- Bypassed
read_commit(is_new = Truefor empty path). - Called
write_commitwithcommit_id=''→ wrote a corrupt file to.muse/commits/.msgpack(empty shard path prefix). - 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_snapshot— hot path is CPU-bound (ignore pattern matching), NOT I/O-bound as originally assumed.cProfileon warm walk (10k files): 76 % of wall time inis_ignored→check_path_with_pattern→_matches→fnmatch.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_workdirmanifest 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_baseon 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 raiseMuseCLIErrorcleanly. →TestFindMergeBasePerformance::test_find_merge_base_5k_depth_under_30s[@slow] →TestFindMergeBaseCorrectness::test_lca_cap_raises_clean_error
Additional tests beyond the plan:
- [x]
diff_snapshotsat 75k files: < 500 ms (actual ~13 ms). →TestDiffSnapshotsAtScale(7 tests) - [x]
detect_conflictssemantics — 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_mergeat 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_baseDAG 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_statewith 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_conflictsreturns{'a.py'}.apply_mergeexcludes conflict paths from both loops, soa.pystays at its base value in the merged manifest. Both parties agreed to delete it; the file was resurrected. ❌ - Fixed:
.get('a.py')isNoneon both sides → not a conflict.apply_mergeprocesses the deletion →a.pyabsent in merged. ✅
Bug B — same-add same-hash: both branches independently add new.py
with identical content hash.
- Old: treated as conflict →
new.pyabsent from merged result (never in base, excluded from loops). ❌ - Fixed: same hash → not a conflict →
apply_mergeincludes 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.py—detect_conflictssignature updatedmuse/cli/commands/merge.py— strategy-path call updatedtests/test_core_merge_engine.py—TestDetectConflictsrewrittentests/test_stress_merge_correctness.py—TestDetectConflictsExhaustiverewritten; pipeline call updatedtests/test_property_merge_invariants.py— 12 pipeline calls updated; M5 rewritten; M8 strengthenedtests/test_merge_data_integrity.py— I5 and pipeline call updatedtests/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:
- every test in that phase is green with
pytest tests/test_extreme_*.py -v. mypy muse/— zero errors.python tools/typing_audit.py --dirs muse/ tests/ --max-any 0— zero violations.muse code breakage --json— zero regressions.muse code docs --stale --json— zero stale docs for touched symbols.- every ⬜ in the phase's table is marked ✅.
- the change is merged to
devand pushed tolocal/dev.