gabriel / muse public
test_security_symlink.py python
613 lines 23.8 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Phase 2.2 — Symlink attack tests.
2
3 Covers every identified attack vector:
4
5 1. ``.muse/`` itself replaced by a symlink → ``find_repo_root`` rejects it.
6 2. Critical subdirectories (``.muse/objects/``, ``.muse/commits/``, etc.)
7 replaced by symlinks → ``require_repo`` detects and exits.
8 3. ``write_object`` / ``write_object_from_path`` detect a symlinked shard dir
9 or objects directory and raise before writing.
10 4. ``write_text_atomic`` / ``_write_msgpack_atomic`` detect a symlinked parent
11 directory and raise before writing.
12 5. ``cleanup_stale_object_temps`` skips symlinked shard directories safely.
13 6. ``_cleanup_muse_dir_temps`` skips symlinked subdirectories safely.
14 7. Tracked-file symlinks are silently skipped by the workdir walker
15 (``os.lstat`` + ``S_ISREG`` filter).
16 8. Stress: 50 concurrent symlink-swap attempts during an object write do not
17 corrupt or redirect any data.
18
19 Each test creates its own isolated temporary directory — no shared state.
20 """
21
22 from __future__ import annotations
23
24 import hashlib
25 import os
26 import pathlib
27 import tempfile
28 import threading
29 import time
30 import uuid
31
32 import pytest
33
34 from muse.core.object_store import (
35 cleanup_stale_object_temps,
36 write_object,
37 write_object_from_path,
38 )
39 from muse.core.repo import _cleanup_muse_dir_temps, _verify_muse_dir_integrity, find_repo_root
40 from muse.core.store import CommitDict, _write_msgpack_atomic, write_text_atomic
41 from muse.core.validation import assert_not_symlink, assert_write_inside_repo
42 from tests.cli_test_helper import CliRunner
43
44
45 # ---------------------------------------------------------------------------
46 # Helpers
47 # ---------------------------------------------------------------------------
48
49
50 def _make_real_repo(tmp_path: pathlib.Path) -> pathlib.Path:
51 """Initialise a minimal real (non-symlinked) ``.muse/`` repo layout."""
52 repo = tmp_path / "repo"
53 repo.mkdir()
54 muse = repo / ".muse"
55 for sub in ("objects", "commits", "snapshots", "refs", "refs/heads", "tags"):
56 (muse / sub).mkdir(parents=True)
57 (muse / "HEAD").write_text("ref: refs/heads/main\n")
58 (muse / "repo.json").write_text('{"repo_id": "test-repo"}')
59 return repo
60
61
62 def _sha256_content(data: bytes) -> str:
63 return hashlib.sha256(data).hexdigest()
64
65
66 # ---------------------------------------------------------------------------
67 # Unit tests — assert_not_symlink / assert_write_inside_repo
68 # ---------------------------------------------------------------------------
69
70
71 class TestAssertNotSymlink:
72 def test_real_dir_passes(self, tmp_path: pathlib.Path) -> None:
73 real = tmp_path / "real"
74 real.mkdir()
75 assert_not_symlink(real, "real dir") # should not raise
76
77 def test_real_file_passes(self, tmp_path: pathlib.Path) -> None:
78 f = tmp_path / "file.txt"
79 f.write_text("hello")
80 assert_not_symlink(f, "file") # should not raise
81
82 def test_nonexistent_passes(self, tmp_path: pathlib.Path) -> None:
83 # A path that does not yet exist is not a symlink.
84 assert_not_symlink(tmp_path / "no-such-path", "ghost")
85
86 def test_symlink_to_dir_raises(self, tmp_path: pathlib.Path) -> None:
87 target = tmp_path / "target"
88 target.mkdir()
89 link = tmp_path / "link"
90 link.symlink_to(target)
91 with pytest.raises(ValueError, match="symbolic link"):
92 assert_not_symlink(link, "test link")
93
94 def test_symlink_to_file_raises(self, tmp_path: pathlib.Path) -> None:
95 target = tmp_path / "target.txt"
96 target.write_text("data")
97 link = tmp_path / "link.txt"
98 link.symlink_to(target)
99 with pytest.raises(ValueError, match="symbolic link"):
100 assert_not_symlink(link)
101
102 def test_dangling_symlink_raises(self, tmp_path: pathlib.Path) -> None:
103 link = tmp_path / "dangling"
104 link.symlink_to(tmp_path / "nonexistent")
105 with pytest.raises(ValueError, match="symbolic link"):
106 assert_not_symlink(link, "dangling link")
107
108 def test_error_message_contains_label(self, tmp_path: pathlib.Path) -> None:
109 link = tmp_path / "evil"
110 link.symlink_to(tmp_path)
111 with pytest.raises(ValueError, match="evil-label"):
112 assert_not_symlink(link, "evil-label")
113
114
115 class TestAssertWriteInsideRepo:
116 def test_path_inside_passes(self, tmp_path: pathlib.Path) -> None:
117 repo = tmp_path / "repo"
118 repo.mkdir()
119 target = repo / ".muse" / "commits" / "abc.msgpack"
120 assert_write_inside_repo(repo, target) # should not raise
121
122 def test_path_outside_raises(self, tmp_path: pathlib.Path) -> None:
123 repo = tmp_path / "repo"
124 repo.mkdir()
125 outside = tmp_path / "other" / "evil.txt"
126 with pytest.raises(ValueError, match="outside the repository root"):
127 assert_write_inside_repo(repo, outside)
128
129 def test_symlink_escaping_raises(self, tmp_path: pathlib.Path) -> None:
130 """If dest resolves outside repo via symlink, the check catches it."""
131 repo = tmp_path / "repo"
132 repo.mkdir()
133 muse = repo / ".muse"
134 muse.mkdir()
135 attacker = tmp_path / "attacker"
136 attacker.mkdir()
137 # Symlink .muse/objects → /tmp/attacker
138 evil_link = muse / "objects"
139 evil_link.symlink_to(attacker)
140 # The destination inside the objects dir resolves to attacker/...
141 # Parenthesise to avoid PosixPath * int precedence error.
142 dest = evil_link / "ab" / ("cd" * 31)
143 with pytest.raises(ValueError, match="outside the repository root"):
144 assert_write_inside_repo(repo, dest)
145
146
147 # ---------------------------------------------------------------------------
148 # find_repo_root — symlinked .muse/ is rejected
149 # ---------------------------------------------------------------------------
150
151
152 class TestFindRepoRootSymlink:
153 def test_real_muse_dir_found(self, tmp_path: pathlib.Path) -> None:
154 repo = _make_real_repo(tmp_path)
155 found = find_repo_root(start=repo)
156 assert found == repo
157
158 def test_symlinked_muse_dir_not_found(self, tmp_path: pathlib.Path) -> None:
159 """If .muse/ is a symlink, find_repo_root must not return that directory."""
160 real_muse = tmp_path / "real_muse_dir"
161 real_muse.mkdir()
162 repo = tmp_path / "repo"
163 repo.mkdir()
164 (repo / ".muse").symlink_to(real_muse)
165 result = find_repo_root(start=repo)
166 assert result is None, (
167 f"find_repo_root should return None for symlinked .muse/, got {result}"
168 )
169
170 def test_dangling_symlink_muse_dir_not_found(self, tmp_path: pathlib.Path) -> None:
171 repo = tmp_path / "repo"
172 repo.mkdir()
173 (repo / ".muse").symlink_to(tmp_path / "nonexistent")
174 assert find_repo_root(start=repo) is None
175
176 def test_symlink_to_symlink_muse_dir_rejected(self, tmp_path: pathlib.Path) -> None:
177 real_muse = tmp_path / "real_muse"
178 real_muse.mkdir()
179 intermediate = tmp_path / "intermediate"
180 intermediate.symlink_to(real_muse)
181 repo = tmp_path / "repo"
182 repo.mkdir()
183 (repo / ".muse").symlink_to(intermediate)
184 assert find_repo_root(start=repo) is None
185
186 def test_env_override_still_requires_real_muse(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
187 """MUSE_REPO_ROOT override: returns None if .muse/ is a symlink."""
188 real_muse = tmp_path / "real_muse"
189 real_muse.mkdir()
190 repo = tmp_path / "repo"
191 repo.mkdir()
192 (repo / ".muse").symlink_to(real_muse)
193 monkeypatch.setenv("MUSE_REPO_ROOT", str(repo))
194 result = find_repo_root()
195 assert result is None
196
197
198 # ---------------------------------------------------------------------------
199 # _verify_muse_dir_integrity — critical subdirs must not be symlinks
200 # ---------------------------------------------------------------------------
201
202
203 class TestVerifyMuseDirIntegrity:
204 def test_clean_repo_passes(self, tmp_path: pathlib.Path) -> None:
205 repo = _make_real_repo(tmp_path)
206 _verify_muse_dir_integrity(repo / ".muse") # must not raise
207
208 @pytest.mark.parametrize("subdir", [
209 "objects",
210 "commits",
211 "snapshots",
212 "refs",
213 "refs/heads",
214 "tags",
215 ])
216 def test_symlinked_subdir_causes_exit(
217 self, tmp_path: pathlib.Path, subdir: str
218 ) -> None:
219 import shutil
220 repo = _make_real_repo(tmp_path)
221 muse = repo / ".muse"
222 attacker = tmp_path / "attacker"
223 attacker.mkdir(parents=True, exist_ok=True)
224 target = muse / subdir
225 # Remove the real directory tree (may be non-empty, e.g. refs/).
226 if target.exists() and not target.is_symlink():
227 shutil.rmtree(target)
228 target.symlink_to(attacker)
229 with pytest.raises(SystemExit):
230 _verify_muse_dir_integrity(muse)
231
232 def test_missing_subdirs_pass(self, tmp_path: pathlib.Path) -> None:
233 """Newly-initialised repos may not have all dirs yet — that's fine."""
234 repo = tmp_path / "fresh"
235 repo.mkdir()
236 muse = repo / ".muse"
237 muse.mkdir()
238 _verify_muse_dir_integrity(muse) # no dirs present yet — must not raise
239
240
241 # ---------------------------------------------------------------------------
242 # write_object — symlinked shard directory is rejected
243 # ---------------------------------------------------------------------------
244
245
246 class TestWriteObjectSymlink:
247 def test_normal_write_succeeds(self, tmp_path: pathlib.Path) -> None:
248 repo = _make_real_repo(tmp_path)
249 content = b"hello world"
250 oid = _sha256_content(content)
251 result = write_object(repo, oid, content)
252 assert result is True
253
254 def test_symlinked_objects_dir_raises(self, tmp_path: pathlib.Path) -> None:
255 """If .muse/objects/ is a symlink, write_object must raise ValueError."""
256 repo = _make_real_repo(tmp_path)
257 attacker = tmp_path / "attacker"
258 attacker.mkdir()
259 import shutil
260 shutil.rmtree(repo / ".muse" / "objects")
261 (repo / ".muse" / "objects").symlink_to(attacker)
262
263 content = b"evil payload"
264 oid = _sha256_content(content)
265 # write_object creates the shard dir, then checks it
266 with pytest.raises((ValueError, SystemExit)):
267 write_object(repo, oid, content)
268 # Verify nothing was written to the attacker dir
269 assert not any(attacker.rglob("*")), "Data must not be written to symlink target"
270
271 def test_symlinked_shard_dir_raises(self, tmp_path: pathlib.Path) -> None:
272 """A symlinked shard dir (e.g. objects/ab/ → /tmp/evil/) is rejected."""
273 repo = _make_real_repo(tmp_path)
274 content = b"shard attack"
275 oid = _sha256_content(content)
276 prefix = oid[:2]
277 attacker = tmp_path / "attacker_shard"
278 attacker.mkdir()
279 shard = repo / ".muse" / "objects" / prefix
280 shard.mkdir(parents=True, exist_ok=True)
281 # Replace real shard dir with symlink
282 import shutil
283 shutil.rmtree(shard)
284 shard.symlink_to(attacker)
285
286 with pytest.raises((ValueError, SystemExit)):
287 write_object(repo, oid, content)
288 assert not any(attacker.rglob("*")), "No data must reach symlink target"
289
290 def test_write_object_from_path_symlinked_objects_dir_raises(
291 self, tmp_path: pathlib.Path
292 ) -> None:
293 repo = _make_real_repo(tmp_path)
294 attacker = tmp_path / "attacker"
295 attacker.mkdir()
296 import shutil
297 shutil.rmtree(repo / ".muse" / "objects")
298 (repo / ".muse" / "objects").symlink_to(attacker)
299
300 src = tmp_path / "source.bin"
301 src.write_bytes(b"from path content")
302 oid = _sha256_content(src.read_bytes())
303 with pytest.raises((ValueError, SystemExit)):
304 write_object_from_path(repo, oid, src)
305 assert not any(attacker.rglob("*")), "Data must not be written to symlink target"
306
307
308 # ---------------------------------------------------------------------------
309 # write_text_atomic — symlinked parent directory is rejected
310 # ---------------------------------------------------------------------------
311
312
313 class TestWriteTextAtomicSymlink:
314 def test_normal_write_succeeds(self, tmp_path: pathlib.Path) -> None:
315 target = tmp_path / "HEAD"
316 write_text_atomic(target, "ref: refs/heads/main\n")
317 assert target.read_text() == "ref: refs/heads/main\n"
318
319 def test_symlinked_parent_raises(self, tmp_path: pathlib.Path) -> None:
320 """If the parent directory is a symlink, write_text_atomic must raise."""
321 real_dir = tmp_path / "real"
322 real_dir.mkdir()
323 attacker = tmp_path / "attacker"
324 attacker.mkdir()
325 link_dir = tmp_path / "link_dir"
326 link_dir.symlink_to(attacker)
327 target = link_dir / "HEAD"
328
329 with pytest.raises(ValueError, match="symbolic link"):
330 write_text_atomic(target, "ref: refs/heads/main\n")
331 # Verify attacker dir untouched
332 assert not any(attacker.iterdir()), "No data must reach symlink target"
333
334 def test_symlink_at_destination_is_replaced(self, tmp_path: pathlib.Path) -> None:
335 """POSIX os.replace on a symlink replaces the symlink entry itself.
336
337 This is the SAFE case: writing HEAD when HEAD is a symlink replaces
338 the symlink with a real file — data goes to .muse/HEAD, not to the
339 symlink target. This test documents that behaviour is preserved.
340 """
341 real_parent = tmp_path / "muse_dir"
342 real_parent.mkdir()
343 elsewhere = tmp_path / "elsewhere.txt"
344 elsewhere.write_text("original")
345
346 head = real_parent / "HEAD"
347 head.symlink_to(elsewhere)
348 assert head.is_symlink()
349
350 write_text_atomic(head, "new content\n")
351
352 # The symlink should be gone — HEAD is now a real file
353 assert not head.is_symlink(), "symlink at destination must be replaced by real file"
354 assert head.read_text() == "new content\n"
355 # The symlink target is untouched
356 assert elsewhere.read_text() == "original"
357
358
359 # ---------------------------------------------------------------------------
360 # _write_msgpack_atomic — symlinked parent directory is rejected
361 # ---------------------------------------------------------------------------
362
363
364 class TestWriteMsgpackAtomicSymlink:
365 def _minimal_commit_dict(self) -> CommitDict:
366 return CommitDict(
367 commit_id="a" * 64,
368 repo_id=str(uuid.uuid4()),
369 branch="main",
370 parent_commit_id=None,
371 parent2_commit_id=None,
372 snapshot_id="b" * 64,
373 message="test commit",
374 author="test",
375 committed_at="2026-01-01T00:00:00+00:00",
376 metadata={},
377 )
378
379 def test_normal_write_succeeds(self, tmp_path: pathlib.Path) -> None:
380 real_dir = tmp_path / "commits"
381 real_dir.mkdir()
382 target = real_dir / "abc.msgpack"
383 _write_msgpack_atomic(target, self._minimal_commit_dict())
384 assert target.exists()
385
386 def test_symlinked_parent_raises(self, tmp_path: pathlib.Path) -> None:
387 attacker = tmp_path / "attacker_commits"
388 attacker.mkdir()
389 link_dir = tmp_path / "commits_link"
390 link_dir.symlink_to(attacker)
391 target = link_dir / "abc.msgpack"
392
393 with pytest.raises(ValueError, match="symbolic link"):
394 _write_msgpack_atomic(target, self._minimal_commit_dict())
395 assert not any(attacker.iterdir()), "No data must reach symlink target"
396
397
398 # ---------------------------------------------------------------------------
399 # cleanup_stale_object_temps — symlinked shards are skipped
400 # ---------------------------------------------------------------------------
401
402
403 class TestCleanupSkipsSymlinks:
404 def test_symlinked_shard_not_entered(self, tmp_path: pathlib.Path) -> None:
405 """cleanup_stale_object_temps must skip symlinked shard directories."""
406 repo = _make_real_repo(tmp_path)
407 attacker = tmp_path / "attacker"
408 attacker.mkdir()
409 # Place a "stale temp" file inside the attacker directory
410 victim = attacker / ".obj-tmp-should-not-be-deleted"
411 victim.write_bytes(b"important attacker data")
412
413 # Replace a shard with a symlink → attacker
414 shard = repo / ".muse" / "objects" / "ab"
415 shard.mkdir(parents=True, exist_ok=True)
416 import shutil
417 shutil.rmtree(shard)
418 shard.symlink_to(attacker)
419
420 removed = cleanup_stale_object_temps(repo)
421 assert removed == 0, "Symlinked shard must not be entered"
422 assert victim.exists(), "File in symlink target must not be deleted"
423
424 def test_real_shards_are_cleaned(self, tmp_path: pathlib.Path) -> None:
425 repo = _make_real_repo(tmp_path)
426 shard = repo / ".muse" / "objects" / "cd"
427 shard.mkdir(parents=True)
428 stale = shard / ".obj-tmp-stale123"
429 stale.write_bytes(b"stale data")
430 # Backdate mtime so the 60-second age gate treats this file as stale.
431 os.utime(stale, (0, 0))
432 removed = cleanup_stale_object_temps(repo)
433 assert removed == 1
434 assert not stale.exists()
435
436
437 class TestCleanupMuseDirSkipsSymlinks:
438 def test_symlinked_subdir_not_entered(self, tmp_path: pathlib.Path) -> None:
439 """_cleanup_muse_dir_temps must skip symlinked subdirectories."""
440 repo = _make_real_repo(tmp_path)
441 attacker = tmp_path / "attacker_commits"
442 attacker.mkdir()
443 victim = attacker / ".muse-tmp-should-not-be-deleted"
444 victim.write_bytes(b"important data")
445
446 muse = repo / ".muse"
447 import shutil
448 shutil.rmtree(muse / "commits")
449 (muse / "commits").symlink_to(attacker)
450
451 removed = _cleanup_muse_dir_temps(muse)
452 assert removed == 0, "Symlinked subdir must not be entered"
453 assert victim.exists(), "File in symlink target must not be deleted"
454
455
456 # ---------------------------------------------------------------------------
457 # Tracked-file symlinks — workdir walker skips them
458 # ---------------------------------------------------------------------------
459
460
461 class TestTrackedFileSymlinks:
462 def test_symlink_to_sensitive_file_not_staged(self, tmp_path: pathlib.Path) -> None:
463 """A tracked file that is a symlink is silently excluded from the manifest.
464
465 The workdir walker uses os.lstat + S_ISREG, so symlinks are never
466 hashed or stored — even if they point to /etc/passwd.
467 """
468 from muse.core.snapshot import build_snapshot_manifest
469
470 repo = _make_real_repo(tmp_path)
471 workdir = repo
472
473 # Create a real file (should be tracked)
474 real_file = workdir / "song.mid"
475 real_file.write_bytes(b"\x4d\x54\x68\x64" + b"\x00" * 10)
476
477 # Create a symlink to a sensitive target
478 sensitive = tmp_path / "sensitive.txt"
479 sensitive.write_text("secret data")
480 evil_link = workdir / "evil.txt"
481 evil_link.symlink_to(sensitive)
482
483 manifest = build_snapshot_manifest(workdir)
484 assert "song.mid" in manifest, "real file must be tracked"
485 assert "evil.txt" not in manifest, "symlink must NOT be in manifest"
486
487 def test_symlink_to_nonexistent_target_not_staged(self, tmp_path: pathlib.Path) -> None:
488 from muse.core.snapshot import build_snapshot_manifest
489
490 repo = _make_real_repo(tmp_path)
491 workdir = repo
492 dangling = workdir / "dangling.txt"
493 dangling.symlink_to(tmp_path / "nonexistent")
494
495 manifest = build_snapshot_manifest(workdir)
496 assert "dangling.txt" not in manifest
497
498
499 # ---------------------------------------------------------------------------
500 # Stress: concurrent symlink-swap during write_object
501 # ---------------------------------------------------------------------------
502
503
504 class TestConcurrentSymlinkSwapStress:
505 def test_concurrent_symlink_swap_does_not_corrupt(
506 self, tmp_path: pathlib.Path
507 ) -> None:
508 """50 concurrent symlink-swap threads racing against write_object.
509
510 write_object either succeeds (writes to the real location) or raises
511 ValueError (detects the symlink). It must never silently write to
512 the attacker-controlled location.
513 """
514 repo = _make_real_repo(tmp_path)
515 attacker = tmp_path / "attacker_stress"
516 attacker.mkdir()
517 objects_dir = repo / ".muse" / "objects"
518
519 content = b"stress test object " + uuid.uuid4().bytes
520 oid = _sha256_content(content)
521 shard_prefix = oid[:2]
522 shard_dir = objects_dir / shard_prefix
523
524 errors: list[str] = []
525 swap_active = threading.Event()
526 stop_swapping = threading.Event()
527
528 def swap_shard() -> None:
529 """Repeatedly swap shard dir between real and symlink."""
530 import shutil
531 while not stop_swapping.is_set():
532 swap_active.set()
533 # Replace real shard with symlink
534 try:
535 if shard_dir.exists() and not shard_dir.is_symlink():
536 shutil.rmtree(shard_dir)
537 shard_dir.symlink_to(attacker)
538 time.sleep(0.0005)
539 # Restore real shard
540 if shard_dir.is_symlink():
541 shard_dir.unlink()
542 shard_dir.mkdir(exist_ok=True)
543 except OSError:
544 pass
545
546 swapper = threading.Thread(target=swap_shard, daemon=True)
547 swapper.start()
548 swap_active.wait(timeout=1.0)
549
550 write_errors = 0
551 write_successes = 0
552 for _ in range(50):
553 try:
554 write_object(repo, oid, content)
555 write_successes += 1
556 except (ValueError, OSError, SystemExit):
557 write_errors += 1
558
559 stop_swapping.set()
560 swapper.join(timeout=2.0)
561
562 # The attacker directory must remain empty regardless of outcome.
563 attacker_files = list(attacker.rglob("*"))
564 if attacker_files:
565 errors.append(
566 f"Data leaked to attacker dir: {[str(f) for f in attacker_files]}"
567 )
568
569 assert not errors, "\n".join(errors)
570 # Sanity: at least some operations completed (either succeeded or were blocked).
571 assert write_successes + write_errors == 50
572
573
574 # ---------------------------------------------------------------------------
575 # Integration: end-to-end CLI commands with symlinked .muse/
576 # ---------------------------------------------------------------------------
577
578
579 class TestCLIWithSymlinkedMuse:
580 def test_muse_status_rejects_symlinked_muse(self, tmp_path: pathlib.Path) -> None:
581 """muse status must fail when .muse/ is a symlink."""
582 real_muse = tmp_path / "real_muse"
583 real_muse.mkdir()
584 for sub in ("objects", "commits", "snapshots", "refs/heads", "tags"):
585 (real_muse / sub).mkdir(parents=True)
586 (real_muse / "HEAD").write_text("ref: refs/heads/main\n")
587 (real_muse / "repo.json").write_text('{"repo_id": "test"}')
588
589 repo = tmp_path / "repo"
590 repo.mkdir()
591 (repo / ".muse").symlink_to(real_muse)
592
593 runner = CliRunner()
594 # find_repo_root won't find a real .muse/ → should exit non-zero
595 result = runner.invoke(None, ["status"], env={"MUSE_REPO_ROOT": str(repo)})
596 assert result.exit_code != 0
597
598 def test_muse_status_accepts_real_muse(self, tmp_path: pathlib.Path) -> None:
599 """muse status does not reject a real .muse/ directory as a symlink."""
600 repo = _make_real_repo(tmp_path)
601 (repo / ".muse" / "config.toml").write_text(
602 "[core]\nauthor = \"test\"\n"
603 )
604 (repo / ".muse" / "refs" / "heads" / "main").write_text("")
605 (repo / ".muse" / "HEAD").write_text("ref: refs/heads/main\n")
606
607 runner = CliRunner()
608 result = runner.invoke(
609 None, ["status"],
610 env={"MUSE_REPO_ROOT": str(repo)},
611 )
612 # Must not complain about symlinks on a real .muse/.
613 assert "symbolic link" not in result.output.lower()
File History 5 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:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago