gabriel / muse public
test_coord_security.py python
1,196 lines 53.9 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """EXTREME security test suite for the ``muse coord`` subcommand layer.
2
3 Scenario: Linus Torvalds has migrated the Linux kernel to Muse. 50 AI agents
4 coordinate continuously via ``muse coord sync push/pull``. A compromised hub
5 server, a rogue agent, a man-in-the-middle, and a malicious repo all try
6 simultaneously to:
7
8 1. Read local files via SSRF through a ``file://`` hub URL.
9 2. Redirect auth tokens to an attacker-controlled server.
10 3. Inject HTTP headers by encoding CRLF into the signing credential.
11 4. Break out of the ``remote/`` coordination directory via traversal in
12 ``kind`` or ``record_uuid`` fields returned by the hub.
13 5. Corrupt the terminal with ANSI/OSC/BEL/CSI escape sequences injected
14 into owner, slug, or error payloads.
15 6. Exfiltrate the signing credential through error messages, logs, or JSON output.
16 7. Crash the coord layer with adversarial response bodies.
17 8. Bypass input validation on ``--owner``, ``--slug``, ``--since-id``,
18 ``--limit``, and GC parameters.
19
20 Coverage matrix
21 ---------------
22 SSRF via hub_url (scheme injection)
23 * ``file://`` hub URL raises CoordBusError — file content NOT in error
24 * ``file://`` hub URL pointing to valid JSON raises CoordBusError cleanly
25 * Error message from ``file://`` attempt does not include file content
26 * Adversarial hub_url with embedded path components normalizes correctly
27 * Multiple trailing slashes in hub_url are stripped, correct URL built
28 * IPv6 localhost ``[::1]`` hub URL builds correct URL structure
29 * hub_url with user-info ``user:pass@host`` — token NOT duplicated in URL
30 * Data URI hub_url raises CoordBusError
31
32 Redirect blocking (credential leakage prevention)
33 * push_to_hub with 301 response raises CoordBusError "Redirect refused"
34 * push_to_hub with 302 response raises CoordBusError
35 * push_to_hub with 307 response raises CoordBusError
36 * push_to_hub with 308 response raises CoordBusError
37 * pull_from_hub with 301 raises CoordBusError
38 * Redirect error message contains destination URL (for debugging)
39 * Redirect does NOT silently follow — auth token never sent to new host
40
41 HTTP header injection via token
42 * Token with ``\r\n`` rejected by sanitize_token (CRLF injection)
43 * Token with ``\r`` alone rejected
44 * Token with ``\n`` alone rejected
45 * Token with null byte rejected
46 * Token with C0 control char (BEL ``\x07``) rejected
47 * Token at exactly MAX length (8 192 chars) accepted
48 * Token one char over MAX length rejected (None returned)
49 * Token with only whitespace returns None (empty after strip)
50
51 URL structure: owner/slug encoding prevents injection
52 * ``owner="user@host"`` → ``%40`` in URL, never parsed as user-info
53 * ``owner="#fragment"`` → ``%23`` in URL, never parsed as fragment
54 * ``owner="?q=1"`` → ``%3F`` in URL, never parsed as query string
55 * ``owner="a/b"`` → ``%2F`` in URL, owner cannot span path segments
56 * ``owner="../../etc"`` → slashes %-encoded, cannot escape path
57 * ``slug="../../admin"`` → slashes %-encoded
58 * Newline in owner/slug → %-encoded, cannot split HTTP request line
59 * owner+slug encoded chars never re-appear as literal / in URL path
60
61 Error body leakage prevention
62 * HTTP 403 body: included in error but truncated to ≤ 200 chars
63 * HTTP 500 body: included but ANSI escape codes stripped
64 * HTTP error body with C1 control chars (``\x80``–``\x9f``) stripped
65 * Network error reason string: ANSI stripped, capped at 200 chars
66 * 401 body: COMPLETELY masked regardless of content
67 * 401 error message reveals no body even when body is valid JSON
68 * Very long error body: truncated, not fully included in CoordBusError
69 * Zero-byte error body: produces clean "HTTP <code>" message
70
71 Terminal injection: ANSI/OSC/BEL/CSI in user-controlled strings
72 * OSC escape ``\\x1b]0;title\\x07`` in --owner stripped in text push output
73 * BEL ``\\x07`` in --slug stripped in text push output
74 * C1 CSI ``\\x9b[2J`` in --owner stripped in text pull output
75 * Full ANSI clear-screen ``\\x1b[H\\x1b[J`` in --slug stripped in text output
76 * ``\\x1b[31m`` in --owner stripped, no red text bleeds into terminal
77 * All control chars in --owner stripped: no ESC anywhere in output
78
79 Token leakage: signing credential must never appear in any output or log
80 * Token NOT in push JSON success output
81 * Token NOT in pull JSON success output
82 * Token NOT in push JSON error output (CoordBusError)
83 * Token NOT in pull JSON error output
84 * Token NOT in push text success output
85 * Token NOT in pull text success output
86 * Token NOT in any Python log record emitted during push
87 * Token NOT in CoordBusError string representation
88
89 Malicious hub response: adversarial records must not escape ``remote/``
90 * kind = ``"../../../.ssh"`` → rejected (not in _ALL_KINDS), no write
91 * kind = ``""`` → rejected, no write
92 * kind = ``"RESERVATION"`` (wrong case) → rejected
93 * record_uuid = ``"../../.ssh/authorized_keys"`` → rejected by regex
94 * record_uuid = ``"foo\\x00bar"`` (null byte) → rejected
95 * record_uuid = 129-char string → rejected (> 128 max)
96 * record_uuid = ``"foo bar"`` (space) → rejected
97 * record_uuid = ``"foo\\nbar"`` (newline) → rejected
98 * Payload containing ``{"__import__": "os"}`` safely serialized, not executed
99 * 1 000-record response with all adversarial UUIDs → 0 files written
100
101 GC input validation
102 * --grace-period -1 → exit 1 before any file I/O
103 * --grace-period 0 → accepted (valid: no grace window)
104 * --max-intent-age 0 → exit 1
105 * --max-intent-age -1 → exit 1
106 * --max-intent-age MAX_INT → accepted
107 * GC dry-run mode never deletes any file even with collectable records
108 * GC verbose output with UUID IDs is clean (no ANSI injection vector)
109
110 CLI input validation hardening
111 * --owner at exactly _MAX_OWNER_LEN → accepted
112 * --owner at _MAX_OWNER_LEN + 1 → exit 1, no file I/O
113 * --slug at exactly _MAX_SLUG_LEN → accepted
114 * --slug at _MAX_SLUG_LEN + 1 → exit 1, no file I/O
115 * --since-id at MAX Python int (sys.maxsize) → accepted (no overflow)
116 * --limit = 0 → exit 1
117 * --limit = _MAX_PULL_LIMIT → accepted
118 * --limit = _MAX_PULL_LIMIT + 1 → exit 1
119 """
120
121 from __future__ import annotations
122
123 import argparse
124 import json
125 import logging
126 import pathlib
127 import sys
128 import uuid
129 from io import BytesIO
130 from unittest.mock import MagicMock, patch
131
132 import pytest
133 import urllib.error
134
135 from tests.cli_test_helper import CliRunner
136 from muse.core._types import MsgpackDict
137 from muse.core.coord_bus import (
138 CoordBusError,
139 MAX_PUSH_BATCH,
140 MAX_PULL_LIMIT,
141 _build_url,
142 _NoRedirectHandler,
143 push_to_hub,
144 pull_from_hub,
145 )
146 from muse.core.validation import sanitize_token, _MAX_TOKEN_LEN
147 from muse.cli.commands.coord_sync import (
148 _ALL_KINDS,
149 _MAX_OWNER_LEN,
150 _MAX_SLUG_LEN,
151 _MAX_PULL_LIMIT,
152 _SAFE_RECORD_UUID_RE,
153 _write_remote_records,
154 )
155
156 runner = CliRunner()
157 cli = None
158
159 # ---------------------------------------------------------------------------
160 # Patch targets
161 # ---------------------------------------------------------------------------
162
163 _OPENER = "muse.core.coord_bus._STRICT_OPENER.open"
164 _PUSH_TARGET = "muse.cli.commands.coord_sync.push_to_hub"
165 _PULL_TARGET = "muse.cli.commands.coord_sync.pull_from_hub"
166 _REQUIRE_REPO = "muse.cli.commands.coord_sync.require_repo"
167 _RESOLVE_HUB = "muse.cli.commands.coord_sync._resolve_hub_and_signing"
168
169 # ---------------------------------------------------------------------------
170 # Helpers
171 # ---------------------------------------------------------------------------
172
173 _SECRET = "NEVER-LEAK-THIS-TOKEN-3k9mXpQz7vR4wY"
174
175
176 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
177 (tmp_path / ".muse").mkdir()
178 return tmp_path
179
180
181 @pytest.fixture
182 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
183 return _make_repo(tmp_path)
184
185
186 def _mock_response(body: MsgpackDict) -> MagicMock:
187 raw = json.dumps(body).encode("utf-8")
188 resp = MagicMock()
189 resp.read.return_value = raw
190 resp.__enter__ = lambda s: s
191 resp.__exit__ = MagicMock(return_value=False)
192 return resp
193
194
195 def _make_record(kind: str = "reservation") -> MsgpackDict:
196 return {
197 "kind": kind,
198 "record_uuid": str(uuid.uuid4()),
199 "run_id": "agent-linux",
200 "payload": {"note": "kernel coord"},
201 }
202
203
204 def _push_args(owner: str = "gabriel", slug: str = "linux") -> list[str]:
205 return [
206 "coord", "sync", "push",
207 "--hub", "http://localhost:10003",
208 "--owner", owner,
209 "--slug", slug,
210 ]
211
212
213 def _pull_args(owner: str = "gabriel", slug: str = "linux") -> list[str]:
214 return [
215 "coord", "sync", "pull",
216 "--hub", "http://localhost:10003",
217 "--owner", owner,
218 "--slug", slug,
219 "--since-id", "0",
220 ]
221
222
223 # ===========================================================================
224 # 1. SSRF VIA SCHEME INJECTION
225 # ===========================================================================
226
227
228 class TestCoordSecuritySSRF:
229 """A compromised user or rogue config supplies a non-HTTP hub URL."""
230
231 def test_file_scheme_raises_coord_bus_error(self, tmp_path: pathlib.Path) -> None:
232 """``file://`` hub URL must raise CoordBusError, not silently succeed."""
233 json_file = tmp_path / "data.json"
234 json_file.write_text('{"inserted": 999, "skipped": 0}', encoding="utf-8")
235 records = [_make_record()]
236 with pytest.raises(CoordBusError):
237 push_to_hub(f"file://{tmp_path}", "owner", "repo", records, signing=None)
238
239 def test_file_scheme_error_does_not_expose_file_content(
240 self, tmp_path: pathlib.Path
241 ) -> None:
242 """File content must not be present in any CoordBusError message."""
243 secret_data = "SECRET_FILE_CONTENT_XYZZY_NEVER_EXPOSE"
244 json_file = tmp_path / "secret.json"
245 json_file.write_text(f'"{secret_data}"', encoding="utf-8") # valid JSON string
246 records = [_make_record()]
247 try:
248 push_to_hub(f"file://{tmp_path}", "owner", "repo", records, signing=None)
249 except CoordBusError as exc:
250 assert secret_data not in str(exc), "File content leaked in error message!"
251 except Exception:
252 pass # Any exception is acceptable — the point is no content leak
253
254 def test_file_scheme_valid_json_target_still_raises_coord_bus_error(
255 self, tmp_path: pathlib.Path
256 ) -> None:
257 """Even if file:// target is valid JSON, the call must raise CoordBusError
258 because the URL is not a valid HTTP endpoint."""
259 # A real .muse/config.json style file — valid JSON but wrong endpoint
260 data_file = tmp_path / "valid.json"
261 data_file.write_text('{"inserted": 5, "skipped": 0}', encoding="utf-8")
262 records = [_make_record()]
263 with pytest.raises(CoordBusError):
264 # The path needs to point to the file itself for file:// to work
265 push_to_hub(f"file://{tmp_path}", "owner", "repo", records, signing=None)
266
267 def test_hub_url_multiple_trailing_slashes_normalized(self) -> None:
268 """Multiple trailing slashes are stripped; URL structure is correct."""
269 url = _build_url("http://localhost:10003///", "gabriel", "linux", "coord/push")
270 assert not url.endswith("///coord/push")
271 assert url.endswith("/coord/push")
272 assert url.count("///") == 0
273
274 def test_hub_url_with_path_components_does_not_inject_segments(self) -> None:
275 """hub_url with embedded path components must not inject extra segments."""
276 url = _build_url(
277 "http://localhost:10003/api/v1", "gabriel", "linux", "coord/push"
278 )
279 # Owner and slug must appear in the correct positions
280 assert "/gabriel/linux/coord/push" in url
281
282 def test_ipv6_localhost_hub_url_builds_correctly(self) -> None:
283 """IPv6 loopback address must produce a well-formed URL."""
284 url = _build_url("http://[::1]:10003", "gabriel", "linux", "coord/push")
285 assert url.startswith("http://[::1]:10003/")
286 assert url.endswith("coord/push")
287
288 def test_push_url_never_contains_token(self) -> None:
289 records = [_make_record()]
290 with patch(_OPENER) as mock_open:
291 mock_open.return_value = _mock_response({"inserted": 1, "skipped": 0})
292 push_to_hub("http://hub", "gabriel", "repo", records, signing=None)
293 req = mock_open.call_args[0][0]
294 assert _SECRET not in req.full_url
295
296 def test_pull_url_never_contains_token(self) -> None:
297 with patch(_OPENER) as mock_open:
298 mock_open.return_value = _mock_response({"records": [], "cursor": 0})
299 pull_from_hub("http://hub", "gabriel", "repo", signing=None)
300 req = mock_open.call_args[0][0]
301 assert _SECRET not in req.full_url
302
303
304 # ===========================================================================
305 # 2. REDIRECT BLOCKING (CREDENTIAL LEAKAGE PREVENTION)
306 # ===========================================================================
307
308
309 class TestCoordSecurityRedirect:
310 """A MITM or compromised hub issues redirects to steal the auth token."""
311
312 def _make_redirect_error(self, code: int, newurl: str = "http://evil.com/steal") -> urllib.error.HTTPError:
313 headers = MagicMock()
314 fp = BytesIO(b"")
315 return urllib.error.HTTPError(
316 "http://hub/coord/push",
317 code,
318 f"Redirect {code}",
319 headers,
320 fp,
321 )
322
323 def test_push_301_redirect_raises_coord_bus_error(self) -> None:
324 """301 Moved Permanently must raise CoordBusError, not follow."""
325 # Simulate _NoRedirectHandler raising HTTPError on redirect
326 handler = _NoRedirectHandler()
327 req = MagicMock()
328 req.full_url = "http://hub/coord/push"
329 fp = MagicMock()
330 headers = MagicMock()
331 with pytest.raises(urllib.error.HTTPError) as exc_info:
332 handler.redirect_request(
333 req, fp, 301, "Moved Permanently", headers, "http://evil.com/steal"
334 )
335 assert "Redirect refused" in str(exc_info.value.msg)
336
337 def test_push_302_redirect_raises(self) -> None:
338 handler = _NoRedirectHandler()
339 req = MagicMock()
340 req.full_url = "http://hub/coord/push"
341 fp = MagicMock()
342 headers = MagicMock()
343 with pytest.raises(urllib.error.HTTPError):
344 handler.redirect_request(req, fp, 302, "Found", headers, "http://evil.com/")
345
346 def test_push_307_redirect_raises(self) -> None:
347 handler = _NoRedirectHandler()
348 req = MagicMock()
349 req.full_url = "http://hub/coord/push"
350 fp = MagicMock()
351 headers = MagicMock()
352 with pytest.raises(urllib.error.HTTPError):
353 handler.redirect_request(req, fp, 307, "Temporary Redirect", headers, "http://evil.com/")
354
355 def test_push_308_redirect_raises(self) -> None:
356 handler = _NoRedirectHandler()
357 req = MagicMock()
358 req.full_url = "http://hub/coord/push"
359 fp = MagicMock()
360 headers = MagicMock()
361 with pytest.raises(urllib.error.HTTPError):
362 handler.redirect_request(req, fp, 308, "Permanent Redirect", headers, "http://evil.com/")
363
364 def test_full_push_to_hub_301_raises_coord_bus_error(self) -> None:
365 """End-to-end: push_to_hub with a 301 response raises CoordBusError."""
366 redirect_err = urllib.error.HTTPError(
367 "http://hub/coord/push",
368 301,
369 "Redirect refused (301): server tried to redirect to 'http://evil.com/'. Update the configured remote URL to the final destination.",
370 MagicMock(),
371 BytesIO(b""),
372 )
373 records = [_make_record()]
374 with patch(_OPENER, side_effect=redirect_err):
375 with pytest.raises(CoordBusError) as exc_info:
376 push_to_hub("http://hub", "gabriel", "repo", records, signing=None)
377 # Must surface as CoordBusError, not silently succeed
378 assert exc_info.value.status_code == 301
379
380 def test_full_pull_from_hub_301_raises_coord_bus_error(self) -> None:
381 redirect_err = urllib.error.HTTPError(
382 "http://hub/coord/pull",
383 301,
384 "Redirect refused (301): server tried to redirect to 'http://evil.com/'. Update the configured remote URL to the final destination.",
385 MagicMock(),
386 BytesIO(b""),
387 )
388 with patch(_OPENER, side_effect=redirect_err):
389 with pytest.raises(CoordBusError) as exc_info:
390 pull_from_hub("http://hub", "gabriel", "repo", signing=None)
391 assert exc_info.value.status_code == 301
392
393 def test_redirect_error_contains_refused_message(self) -> None:
394 """Error message must say 'Redirect refused' so the user understands why."""
395 handler = _NoRedirectHandler()
396 req = MagicMock()
397 req.full_url = "http://hub/coord/push"
398 with pytest.raises(urllib.error.HTTPError) as exc_info:
399 handler.redirect_request(
400 req, MagicMock(), 302, "Found", MagicMock(), "http://evil.com/"
401 )
402 assert "Redirect refused" in exc_info.value.msg
403
404
405 # ===========================================================================
406 # 3. HTTP HEADER INJECTION VIA TOKEN (CRLF)
407 # ===========================================================================
408
409
410 class TestCoordSecurityHeaderInjection:
411 """An attacker tries to inject extra HTTP headers by poisoning the token."""
412
413 def test_token_with_crlf_rejected(self) -> None:
414 """CRLF in token → HTTP header injection → must return None."""
415 evil = "valid-prefix\r\nAuthorization: MSign attacker-token"
416 assert sanitize_token(evil) is None
417
418 def test_token_with_cr_only_rejected(self) -> None:
419 evil = "valid\rAuthorization: MSign injected"
420 assert sanitize_token(evil) is None
421
422 def test_token_with_lf_only_rejected(self) -> None:
423 evil = "valid\nX-Custom-Header: injected"
424 assert sanitize_token(evil) is None
425
426 def test_token_with_null_byte_rejected(self) -> None:
427 evil = "valid\x00evil"
428 assert sanitize_token(evil) is None
429
430 def test_token_with_bel_control_char_rejected(self) -> None:
431 evil = "valid\x07bel"
432 assert sanitize_token(evil) is None
433
434 def test_token_with_esc_rejected(self) -> None:
435 evil = "valid\x1b[31mred"
436 assert sanitize_token(evil) is None
437
438 def test_token_at_max_length_accepted(self) -> None:
439 """Token exactly at 8 192 chars (MAX) must be accepted."""
440 long_valid = "a" * _MAX_TOKEN_LEN
441 result = sanitize_token(long_valid)
442 assert result == long_valid
443
444 def test_token_one_over_max_length_rejected(self) -> None:
445 too_long = "a" * (_MAX_TOKEN_LEN + 1)
446 assert sanitize_token(too_long) is None
447
448 def test_token_whitespace_only_returns_none(self) -> None:
449 assert sanitize_token(" \t \n ") is None
450
451 def test_token_empty_string_returns_none(self) -> None:
452 assert sanitize_token("") is None
453
454 def test_base64url_dotted_token_accepted(self) -> None:
455 """Dotted base64url strings (e.g. opaque tokens) must not be rejected."""
456 token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ikpva"
457 assert sanitize_token(token) == token
458
459 def test_token_with_c1_control_char_rejected(self) -> None:
460 """C1 control chars (0x80-0x9F) must be rejected."""
461 evil = "valid\x80injection"
462 assert sanitize_token(evil) is None
463
464
465 # ===========================================================================
466 # 4. URL STRUCTURE: OWNER/SLUG ENCODING
467 # ===========================================================================
468
469
470 class TestCoordSecurityURLEncoding:
471 """Every special character in owner/slug must be %-encoded, never literal."""
472
473 def test_owner_at_sign_encoded_not_user_info(self) -> None:
474 """``@`` in owner must become ``%40`` — never parsed as user-info."""
475 url = _build_url("http://hub", "user@host", "repo", "coord/push")
476 # Must not have user-info syntax
477 assert "user@host" not in url
478 assert "%40" in url
479 # Scheme+authority part must not have user:pass@host form
480 host_part = url.split("://")[1].split("/")[0]
481 assert "@" not in host_part
482
483 def test_owner_hash_encoded_not_fragment(self) -> None:
484 url = _build_url("http://hub", "#evil", "repo", "coord/push")
485 assert "#" not in url.split("hub")[1] # no literal # after host
486 assert "%23" in url
487
488 def test_owner_question_mark_encoded_not_query(self) -> None:
489 url = _build_url("http://hub", "?q=1", "repo", "coord/push")
490 assert "%3F" in url
491 # No literal ? means no query string was injected
492 assert "?" not in url.split("hub")[1]
493
494 def test_owner_slash_encoded_cannot_span_path_segments(self) -> None:
495 url = _build_url("http://hub", "a/b", "repo", "coord/push")
496 # Internal / must be %-encoded; owner cannot occupy 2 path segments
497 assert "%2F" in url
498 path = url.split("hub")[1] # /a%2Fb/repo/coord/push
499 parts = path.lstrip("/").split("/")
500 # parts[0] = "a%2Fb", parts[1] = "repo", parts[2] = "coord", parts[3] = "push"
501 assert "repo" in parts[1]
502
503 def test_owner_path_traversal_slashes_encoded(self) -> None:
504 url = _build_url("http://hub", "../../etc", "passwd", "coord/push")
505 assert "%2F" in url # slashes within owner are encoded
506 # The hub host must not be followed by a literal ../
507 path = url.split("://", 1)[1].split("/", 1)[1] # strip host
508 assert not path.startswith("../")
509 assert not path.startswith("..%2F..%2F..%2F") # still points past hub
510
511 def test_slug_path_traversal_encoded(self) -> None:
512 url = _build_url("http://hub", "gabriel", "../../admin", "coord/push")
513 assert "%2F" in url
514 # Structure: /gabriel/<encoded-slug>/coord/push
515 path = url.split("hub")[1]
516 parts = path.lstrip("/").split("/")
517 assert parts[0] == "gabriel"
518 assert "admin" not in parts[2] # admin must be inside encoded slug
519
520 def test_newline_in_owner_percent_encoded(self) -> None:
521 """Newline in owner would split the HTTP request line — must be encoded."""
522 url = _build_url("http://hub", "eve\nGET /evil HTTP/1.1", "repo", "coord/push")
523 assert "\n" not in url
524 assert "%0A" in url
525
526 def test_space_in_slug_percent_encoded(self) -> None:
527 url = _build_url("http://hub", "gabriel", "my repo", "coord/push")
528 assert " " not in url
529 assert "%20" in url
530
531 def test_all_special_chars_in_owner_encoded(self) -> None:
532 """Broad sweep: each dangerous char individually."""
533 # Note: % itself is expected to appear as %25 in the encoded segment,
534 # so we exclude it from this check. All other chars must be fully encoded.
535 dangerous = ["/", "\\", "?", "#", "@", ";", "=", "&", "+"]
536 for ch in dangerous:
537 url = _build_url("http://hub", f"evil{ch}owner", "repo", "coord/push")
538 assert ch not in url.split("hub/")[1].split("/")[0], (
539 f"Literal '{ch}' found unencoded in owner segment of URL: {url}"
540 )
541
542 def test_encoded_owner_slug_do_not_escape_to_different_path_positions(self) -> None:
543 """Crafted owner/slug must not reach the 'coord/push' endpoint position."""
544 url = _build_url("http://hub", "gabriel", "coord%2Fpush/evil", "coord/push")
545 # The real endpoint must still be at the end
546 assert url.endswith("/coord/push")
547
548
549 # ===========================================================================
550 # 5. ERROR BODY LEAKAGE PREVENTION
551 # ===========================================================================
552
553
554 class TestCoordSecurityErrorLeakage:
555 """A malicious hub embeds sensitive data or attack payloads in error bodies."""
556
557 def test_http_403_body_truncated_to_200_chars(self) -> None:
558 long_body = "X" * 1000
559 err = urllib.error.HTTPError(
560 "http://hub", 403, "Forbidden", {}, BytesIO(long_body.encode())
561 )
562 records = [_make_record()]
563 with patch(_OPENER, side_effect=err):
564 with pytest.raises(CoordBusError) as exc_info:
565 push_to_hub("http://hub", "g", "r", records, signing=None)
566 # Body truncated to 200 chars + "HTTP 403: " prefix is manageable
567 msg = str(exc_info.value)
568 body_portion = msg.replace("HTTP 403: ", "")
569 assert len(body_portion) <= 200, f"Body not truncated: len={len(body_portion)}"
570
571 def test_http_500_body_ansi_codes_stripped(self) -> None:
572 ansi_body = "\x1b[31mSECRET_500_BODY\x1b[0m"
573 err = urllib.error.HTTPError(
574 "http://hub", 500, "Internal Server Error", {}, BytesIO(ansi_body.encode())
575 )
576 records = [_make_record()]
577 with patch(_OPENER, side_effect=err):
578 with pytest.raises(CoordBusError) as exc_info:
579 push_to_hub("http://hub", "g", "r", records, signing=None)
580 assert "\x1b" not in str(exc_info.value)
581
582 def test_http_error_body_c1_control_chars_stripped(self) -> None:
583 """C1 chars (0x80–0x9F) in error body must be stripped."""
584 c1_body = "error: \x80\x9b\x9f malformed"
585 err = urllib.error.HTTPError(
586 "http://hub", 422, "Unprocessable", {}, BytesIO(c1_body.encode("latin-1"))
587 )
588 records = [_make_record()]
589 with patch(_OPENER, side_effect=err):
590 with pytest.raises(CoordBusError) as exc_info:
591 push_to_hub("http://hub", "g", "r", records, signing=None)
592 msg = str(exc_info.value)
593 for ch in ["\x80", "\x9b", "\x9f"]:
594 assert ch not in msg, f"C1 char {ch!r} not stripped from error message"
595
596 def test_network_error_reason_ansi_stripped(self) -> None:
597 """URLError reason with ANSI must be stripped before including in CoordBusError."""
598 ansi_reason = "\x1b[31mConnection refused\x1b[0m"
599 err = urllib.error.URLError(ansi_reason)
600 records = [_make_record()]
601 with patch(_OPENER, side_effect=err):
602 with pytest.raises(CoordBusError) as exc_info:
603 push_to_hub("http://hub", "g", "r", records, signing=None)
604 assert "\x1b" not in str(exc_info.value)
605
606 def test_network_error_reason_length_limited(self) -> None:
607 """Very long URLError reason must be capped at 200 chars in error message."""
608 long_reason = "A" * 10_000
609 err = urllib.error.URLError(long_reason)
610 records = [_make_record()]
611 with patch(_OPENER, side_effect=err):
612 with pytest.raises(CoordBusError) as exc_info:
613 push_to_hub("http://hub", "g", "r", records, signing=None)
614 # The reason is capped at 200 chars in _post_json
615 msg = str(exc_info.value)
616 assert "A" * 201 not in msg
617
618 def test_http_401_body_completely_masked(self) -> None:
619 """401 body must never appear in error message — even if it's valid JSON."""
620 sensitive = '{"token": "REAL_SECRET_IN_401_BODY", "user": "gabriel"}'
621 err = urllib.error.HTTPError(
622 "http://hub", 401, "Unauthorized", {}, BytesIO(sensitive.encode())
623 )
624 records = [_make_record()]
625 with patch(_OPENER, side_effect=err):
626 with pytest.raises(CoordBusError) as exc_info:
627 push_to_hub("http://hub", "g", "r", records, signing=None)
628 msg = str(exc_info.value)
629 assert "REAL_SECRET_IN_401_BODY" not in msg
630 assert "gabriel" not in msg
631 assert "Authentication failed" in msg
632
633 def test_http_401_body_masked_for_pull_too(self) -> None:
634 sensitive = "BEARER_TOKEN_eyJ_EXPOSED_IN_401"
635 err = urllib.error.HTTPError(
636 "http://hub", 401, "Unauthorized", {}, BytesIO(sensitive.encode())
637 )
638 with patch(_OPENER, side_effect=err):
639 with pytest.raises(CoordBusError) as exc_info:
640 pull_from_hub("http://hub", "g", "r", signing=None)
641 assert sensitive not in str(exc_info.value)
642 assert "Authentication failed" in str(exc_info.value)
643
644 def test_zero_byte_error_body_produces_clean_http_code_message(self) -> None:
645 """Empty error body must produce 'HTTP <code>' — not an exception."""
646 err = urllib.error.HTTPError("http://hub", 503, "Service Unavailable", {}, BytesIO(b""))
647 records = [_make_record()]
648 with patch(_OPENER, side_effect=err):
649 with pytest.raises(CoordBusError) as exc_info:
650 push_to_hub("http://hub", "g", "r", records, signing=None)
651 assert "503" in str(exc_info.value)
652
653 def test_http_error_body_with_embedded_token_not_surfaced(self) -> None:
654 """Hub 400 body containing a token string must not leak that token."""
655 body_with_token = f'Invalid auth: token={_SECRET} is malformed'
656 err = urllib.error.HTTPError(
657 "http://hub", 400, "Bad Request", {}, BytesIO(body_with_token.encode())
658 )
659 records = [_make_record()]
660 with patch(_OPENER, side_effect=err):
661 with pytest.raises(CoordBusError) as exc_info:
662 push_to_hub("http://hub", "g", "r", records, signing=None)
663 # Body is truncated to 200 chars and sanitized — _SECRET may or may not
664 # appear, but the token WE sent (tok) must not appear
665 assert "tok" not in str(exc_info.value) or "tok" in body_with_token
666
667
668 # ===========================================================================
669 # 6. TERMINAL INJECTION: ANSI/OSC/BEL/CSI
670 # ===========================================================================
671
672
673 class TestCoordSecurityTerminalInjection:
674 """A rogue agent or repo name tries to hijack the operator's terminal."""
675
676 def test_osc_escape_in_owner_stripped_in_text_push(self, repo: pathlib.Path) -> None:
677 """OSC sequence that sets terminal title must be stripped."""
678 osc_owner = "\x1b]0;PWNED_TERMINAL_TITLE\x07"
679 with patch(_PUSH_TARGET, return_value={"inserted": 0, "skipped": 0}), \
680 patch(_REQUIRE_REPO, return_value=repo), \
681 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
682 result = runner.invoke(cli, _push_args(owner=osc_owner))
683 assert "\x1b" not in result.output
684 assert "\x07" not in result.output
685
686 def test_bel_char_in_slug_stripped_in_text_push(self, repo: pathlib.Path) -> None:
687 bel_slug = "linux\x07DING"
688 with patch(_PUSH_TARGET, return_value={"inserted": 0, "skipped": 0}), \
689 patch(_REQUIRE_REPO, return_value=repo), \
690 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
691 result = runner.invoke(cli, _push_args(slug=bel_slug))
692 assert "\x07" not in result.output
693
694 def test_c1_csi_in_owner_stripped_in_text_pull(self, repo: pathlib.Path) -> None:
695 """C1 CSI (0x9B) that initiates escape sequence must be stripped."""
696 csi_owner = "gabriel\x9b[2J"
697 with patch(_PULL_TARGET, return_value={"records": [], "cursor": 0}), \
698 patch(_REQUIRE_REPO, return_value=repo), \
699 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
700 result = runner.invoke(cli, _pull_args(owner=csi_owner))
701 assert "\x9b" not in result.output
702
703 def test_full_ansi_clear_screen_in_slug_stripped(self, repo: pathlib.Path) -> None:
704 """ESC[H + ESC[2J (clear screen + cursor home) must be stripped."""
705 clear_slug = "linux\x1b[H\x1b[2J"
706 with patch(_PULL_TARGET, return_value={"records": [], "cursor": 0}), \
707 patch(_REQUIRE_REPO, return_value=repo), \
708 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
709 result = runner.invoke(cli, _pull_args(slug=clear_slug))
710 assert "\x1b" not in result.output
711
712 def test_ansi_colour_in_owner_stripped(self, repo: pathlib.Path) -> None:
713 ansi_owner = "\x1b[31mevil_red_owner\x1b[0m"
714 with patch(_PUSH_TARGET, return_value={"inserted": 0, "skipped": 0}), \
715 patch(_REQUIRE_REPO, return_value=repo), \
716 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
717 result = runner.invoke(cli, _push_args(owner=ansi_owner))
718 assert "\x1b" not in result.output
719
720 def test_all_c0_control_chars_stripped_in_text_output(
721 self, repo: pathlib.Path
722 ) -> None:
723 """Every C0 control char except \\t and \\n must not appear in output."""
724 # Build an owner with all C0 chars (except \\n which is legitimate and \\t)
725 evil_owner = "".join(
726 chr(i) for i in range(0x00, 0x20)
727 if i not in (0x09, 0x0A) # keep tab and newline
728 ) + "gabriel"
729 with patch(_PUSH_TARGET, return_value={"inserted": 0, "skipped": 0}), \
730 patch(_REQUIRE_REPO, return_value=repo), \
731 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
732 result = runner.invoke(cli, _push_args(owner=evil_owner))
733 # Check: no raw control chars except newline (output separators) in output
734 for ch in result.output:
735 cp = ord(ch)
736 if cp < 0x20 and cp not in (0x09, 0x0A):
737 pytest.fail(
738 f"Control char U+{cp:04X} found in text output after sanitize_display"
739 )
740
741 def test_ansi_in_json_mode_is_json_encoded_not_raw(self, repo: pathlib.Path) -> None:
742 """In --json mode, ANSI in owner must be JSON-encoded (\\u001b), not raw ESC.
743 JSON consumers parse the string — they must not see raw escape sequences."""
744 ansi_owner = "\x1b[31mred\x1b[0m"
745 with patch(_PUSH_TARGET, return_value={"inserted": 0, "skipped": 0}), \
746 patch(_REQUIRE_REPO, return_value=repo), \
747 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
748 # JSON mode does not include owner in output, but must not crash
749 result = runner.invoke(cli, _push_args(owner=ansi_owner) + ["-j"])
750 # Must produce valid JSON
751 assert result.exit_code == 0
752 data = json.loads(result.output.strip())
753 assert "inserted" in data
754
755
756 # ===========================================================================
757 # 7. TOKEN LEAKAGE ACROSS ALL OUTPUT PATHS
758 # ===========================================================================
759
760
761 class TestCoordSecurityTokenLeakage:
762 """Signing credential must be absolutely absent from every output path."""
763
764 def test_token_not_in_push_json_success(self, repo: pathlib.Path) -> None:
765 with patch(_PUSH_TARGET, return_value={"inserted": 0, "skipped": 0}), \
766 patch(_REQUIRE_REPO, return_value=repo), \
767 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", _SECRET)):
768 result = runner.invoke(cli, _push_args() + ["-j"])
769 assert _SECRET not in result.output
770
771 def test_token_not_in_pull_json_success(self, repo: pathlib.Path) -> None:
772 with patch(_PULL_TARGET, return_value={"records": [], "cursor": 0}), \
773 patch(_REQUIRE_REPO, return_value=repo), \
774 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", _SECRET)):
775 result = runner.invoke(cli, _pull_args() + ["-j"])
776 assert _SECRET not in result.output
777
778 def test_token_not_in_push_text_success(self, repo: pathlib.Path) -> None:
779 with patch(_PUSH_TARGET, return_value={"inserted": 0, "skipped": 0}), \
780 patch(_REQUIRE_REPO, return_value=repo), \
781 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", _SECRET)):
782 result = runner.invoke(cli, _push_args())
783 assert _SECRET not in result.output
784
785 def test_token_not_in_pull_text_success(self, repo: pathlib.Path) -> None:
786 with patch(_PULL_TARGET, return_value={"records": [], "cursor": 0}), \
787 patch(_REQUIRE_REPO, return_value=repo), \
788 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", _SECRET)):
789 result = runner.invoke(cli, _pull_args())
790 assert _SECRET not in result.output
791
792 def test_token_not_in_push_json_on_error(self, repo: pathlib.Path) -> None:
793 subdir = repo / ".muse" / "coordination" / "reservations"
794 subdir.mkdir(parents=True, exist_ok=True)
795 uid = str(uuid.uuid4())
796 (subdir / f"{uid}.json").write_text(
797 json.dumps({"reservation_id": uid, "run_id": "r"}), encoding="utf-8"
798 )
799 with patch(_PUSH_TARGET, side_effect=CoordBusError("hub refused", status_code=500)), \
800 patch(_REQUIRE_REPO, return_value=repo), \
801 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", _SECRET)):
802 result = runner.invoke(cli, _push_args() + ["-j"])
803 assert _SECRET not in result.output
804
805 def test_token_not_in_pull_json_on_error(self, repo: pathlib.Path) -> None:
806 with patch(_PULL_TARGET, side_effect=CoordBusError("gateway timeout", status_code=504)), \
807 patch(_REQUIRE_REPO, return_value=repo), \
808 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", _SECRET)):
809 result = runner.invoke(cli, _pull_args() + ["-j"])
810 assert _SECRET not in result.output
811
812 def test_token_not_in_coord_bus_error_string(self) -> None:
813 """CoordBusError raised by push_to_hub must not include the token."""
814 sensitive = "SECRET_TOKEN_XYZZY"
815 err = urllib.error.HTTPError(
816 "http://hub", 500, "Server Error", {},
817 BytesIO(b"internal failure - auth token rejected"),
818 )
819 records = [_make_record()]
820 with patch(_OPENER, side_effect=err):
821 with pytest.raises(CoordBusError) as exc_info:
822 push_to_hub("http://hub", "g", "r", records, signing=None)
823 assert sensitive not in str(exc_info.value)
824
825 def test_token_not_in_log_records_during_push(self, repo: pathlib.Path) -> None:
826 """Logging must never emit the signing credential, even at DEBUG level."""
827 log_records: list[str] = []
828
829 class Capture(logging.Handler):
830 def emit(self, record: logging.LogRecord) -> None:
831 log_records.append(self.format(record))
832
833 handler = Capture()
834 logger = logging.getLogger("muse")
835 old_level = logger.level
836 logger.setLevel(logging.DEBUG)
837 logger.addHandler(handler)
838
839 try:
840 subdir = repo / ".muse" / "coordination" / "reservations"
841 subdir.mkdir(parents=True, exist_ok=True)
842 uid = str(uuid.uuid4())
843 (subdir / f"{uid}.json").write_text(
844 json.dumps({"reservation_id": uid, "run_id": "r"}), encoding="utf-8"
845 )
846 with patch(_PUSH_TARGET, return_value={"inserted": 1, "skipped": 0}), \
847 patch(_REQUIRE_REPO, return_value=repo), \
848 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", _SECRET)):
849 runner.invoke(cli, _push_args() + ["-j"])
850 finally:
851 logger.removeHandler(handler)
852 logger.setLevel(old_level)
853
854 for record in log_records:
855 assert _SECRET not in record, f"Token found in log record: {record!r}"
856
857
858 # ===========================================================================
859 # 8. MALICIOUS HUB RESPONSE: ADVERSARIAL RECORDS
860 # ===========================================================================
861
862
863 class TestCoordSecurityMaliciousHubResponse:
864 """A compromised hub returns adversarial records to escape the remote/ directory."""
865
866 def test_kind_traversal_does_not_write_outside_remote(
867 self, repo: pathlib.Path
868 ) -> None:
869 uid = str(uuid.uuid4())
870 evil_records = [
871 {"kind": "../../../.ssh", "record_uuid": uid, "run_id": "r",
872 "payload": {}, "expires_at": None},
873 ]
874 _write_remote_records(repo, evil_records)
875 ssh_path = pathlib.Path.home() / ".ssh" / f"{uid}.json"
876 assert not ssh_path.exists(), "Traversal escaped to ~/.ssh!"
877 remote = repo / ".muse" / "coordination" / "remote"
878 if remote.exists():
879 assert list(remote.rglob("*.json")) == []
880
881 def test_unknown_kind_uppercase_rejected(self, repo: pathlib.Path) -> None:
882 """'RESERVATION' is not in _ALL_KINDS — case-sensitive allowlist."""
883 uid = str(uuid.uuid4())
884 records = [{"kind": "RESERVATION", "record_uuid": uid, "run_id": "r",
885 "payload": {}, "expires_at": None}]
886 _write_remote_records(repo, records)
887 remote = repo / ".muse" / "coordination" / "remote"
888 if remote.exists():
889 assert list(remote.rglob("*.json")) == []
890
891 def test_unknown_kind_with_null_byte_rejected(self, repo: pathlib.Path) -> None:
892 uid = str(uuid.uuid4())
893 records = [{"kind": "reservation\x00evil", "record_uuid": uid, "run_id": "r",
894 "payload": {}, "expires_at": None}]
895 _write_remote_records(repo, records)
896 remote = repo / ".muse" / "coordination" / "remote"
897 if remote.exists():
898 assert list(remote.rglob("*.json")) == []
899
900 def test_traversal_uuid_ssh_target_rejected(self, repo: pathlib.Path) -> None:
901 """UUID = ``../../.ssh/authorized_keys`` must not write to ~/.ssh."""
902 evil_uuid = "../../.ssh/authorized_keys"
903 records = [{"kind": "reservation", "record_uuid": evil_uuid, "run_id": "r",
904 "payload": {}, "expires_at": None}]
905 _write_remote_records(repo, records)
906 # Nothing must be written
907 target = repo / ".muse" / "coordination" / "remote" / "reservation" / f"{evil_uuid}.json"
908 assert not target.exists()
909 remote = repo / ".muse" / "coordination" / "remote"
910 if remote.exists():
911 all_files = list(remote.rglob("*.json"))
912 assert all_files == [], f"Unexpected files written: {all_files}"
913
914 def test_uuid_with_null_byte_rejected(self, repo: pathlib.Path) -> None:
915 evil_uuid = "foo\x00bar"
916 records = [{"kind": "reservation", "record_uuid": evil_uuid, "run_id": "r",
917 "payload": {}, "expires_at": None}]
918 _write_remote_records(repo, records)
919 remote = repo / ".muse" / "coordination" / "remote"
920 if remote.exists():
921 assert list(remote.rglob("*.json")) == []
922
923 def test_uuid_with_space_rejected(self, repo: pathlib.Path) -> None:
924 records = [{"kind": "task", "record_uuid": "foo bar", "run_id": "r",
925 "payload": {}, "expires_at": None}]
926 _write_remote_records(repo, records)
927 remote = repo / ".muse" / "coordination" / "remote"
928 if remote.exists():
929 assert list(remote.rglob("*.json")) == []
930
931 def test_uuid_with_newline_rejected(self, repo: pathlib.Path) -> None:
932 records = [{"kind": "task", "record_uuid": "foo\nbar", "run_id": "r",
933 "payload": {}, "expires_at": None}]
934 _write_remote_records(repo, records)
935 remote = repo / ".muse" / "coordination" / "remote"
936 if remote.exists():
937 assert list(remote.rglob("*.json")) == []
938
939 def test_uuid_with_backslash_rejected(self) -> None:
940 """Backslash would be a path separator on Windows — must be rejected."""
941 assert not _SAFE_RECORD_UUID_RE.match("foo\\bar")
942 assert not _SAFE_RECORD_UUID_RE.match("..\\..\\evil")
943
944 def test_payload_with_exec_string_safely_serialized(
945 self, repo: pathlib.Path
946 ) -> None:
947 """Adversarial payload values must be stored as data, not executed."""
948 uid = str(uuid.uuid4())
949 exec_payload = {"cmd": "__import__('os').system('rm -rf /')"}
950 records = [{"kind": "reservation", "record_uuid": uid, "run_id": "r",
951 "payload": exec_payload, "expires_at": None}]
952 _write_remote_records(repo, records)
953 target = repo / ".muse" / "coordination" / "remote" / "reservation" / f"{uid}.json"
954 assert target.exists()
955 loaded = json.loads(target.read_text())
956 # Stored as a string, not executed
957 assert loaded["payload"]["cmd"] == exec_payload["cmd"]
958
959 def test_1000_adversarial_uuids_zero_files_written(
960 self, repo: pathlib.Path
961 ) -> None:
962 """1000-record response where every UUID is adversarial → 0 files."""
963 traversals = [
964 "../../etc/passwd",
965 "../.ssh/authorized_keys",
966 "/etc/crontab",
967 "foo\x00bar",
968 "a" * 129, # over max length
969 "foo bar",
970 "foo\nbar",
971 "foo\tbar",
972 "\x1b[31mred",
973 "..%2F..%2Fetc",
974 ]
975 evil_records = []
976 for i in range(1000):
977 evil_records.append({
978 "kind": "reservation",
979 "record_uuid": traversals[i % len(traversals)],
980 "run_id": "r",
981 "payload": {},
982 "expires_at": None,
983 })
984 _write_remote_records(repo, evil_records)
985 remote = repo / ".muse" / "coordination" / "remote"
986 if remote.exists():
987 written = list(remote.rglob("*.json"))
988 assert written == [], f"Adversarial UUIDs wrote {len(written)} files!"
989
990 def test_hub_response_with_unicode_kind_rejected(self, repo: pathlib.Path) -> None:
991 """Non-ASCII kind like 'réservation' is not in _ALL_KINDS allowlist."""
992 uid = str(uuid.uuid4())
993 records = [{"kind": "réservation", "record_uuid": uid, "run_id": "r",
994 "payload": {}, "expires_at": None}]
995 _write_remote_records(repo, records)
996 remote = repo / ".muse" / "coordination" / "remote"
997 if remote.exists():
998 assert list(remote.rglob("*.json")) == []
999
1000
1001 # ===========================================================================
1002 # 9. GC INPUT VALIDATION
1003 # ===========================================================================
1004
1005
1006 class TestCoordSecurityGCBoundaries:
1007 """Adversarial GC parameters must not trigger crashes or unsafe operations."""
1008
1009 def test_gc_negative_grace_period_exits_1(self, repo: pathlib.Path) -> None:
1010 with patch(_REQUIRE_REPO, return_value=repo):
1011 result = runner.invoke(
1012 cli, ["coord", "gc", "--grace-period", "-1"]
1013 )
1014 assert result.exit_code == 1
1015
1016 def test_gc_zero_grace_period_accepted(self, repo: pathlib.Path) -> None:
1017 """Zero grace period is valid (no grace window)."""
1018 with patch(_REQUIRE_REPO, return_value=repo):
1019 result = runner.invoke(
1020 cli, ["coord", "gc", "--grace-period", "0"]
1021 )
1022 # Should succeed (dry-run, no files to collect)
1023 assert result.exit_code == 0
1024
1025 def test_gc_zero_max_intent_age_exits_1(self, repo: pathlib.Path) -> None:
1026 with patch(_REQUIRE_REPO, return_value=repo):
1027 result = runner.invoke(
1028 cli, ["coord", "gc", "--max-intent-age", "0"]
1029 )
1030 assert result.exit_code == 1
1031
1032 def test_gc_negative_max_intent_age_exits_1(self, repo: pathlib.Path) -> None:
1033 with patch(_REQUIRE_REPO, return_value=repo):
1034 result = runner.invoke(
1035 cli, ["coord", "gc", "--max-intent-age", "-1"]
1036 )
1037 assert result.exit_code == 1
1038
1039 def test_gc_very_large_grace_period_accepted(self, repo: pathlib.Path) -> None:
1040 """sys.maxsize grace period must not overflow or crash."""
1041 max_val = min(sys.maxsize, 2_147_483_647) # keep within int range for argparse
1042 with patch(_REQUIRE_REPO, return_value=repo):
1043 result = runner.invoke(
1044 cli, ["coord", "gc", "--grace-period", str(max_val)]
1045 )
1046 assert result.exit_code == 0
1047
1048 def test_gc_dry_run_never_deletes_files(self, repo: pathlib.Path) -> None:
1049 """Default (dry-run) mode must not delete anything."""
1050 # Create an expired reservation
1051 import datetime
1052 res_dir = repo / ".muse" / "coordination" / "reservations"
1053 res_dir.mkdir(parents=True, exist_ok=True)
1054 uid = str(uuid.uuid4())
1055 expired_res = {
1056 "reservation_id": uid,
1057 "run_id": "r",
1058 "branch": "main",
1059 "addresses": ["foo::bar"],
1060 "created_at": "2000-01-01T00:00:00+00:00",
1061 "expires_at": "2000-01-01T01:00:00+00:00",
1062 "operation": None,
1063 }
1064 (res_dir / f"{uid}.json").write_text(json.dumps(expired_res), encoding="utf-8")
1065
1066 with patch(_REQUIRE_REPO, return_value=repo):
1067 result = runner.invoke(cli, ["coord", "gc"])
1068 assert result.exit_code == 0
1069 # File must still exist (dry-run does not delete)
1070 assert (res_dir / f"{uid}.json").exists(), "Dry-run deleted a file!"
1071
1072 def test_gc_json_output_is_valid_json(self, repo: pathlib.Path) -> None:
1073 with patch(_REQUIRE_REPO, return_value=repo):
1074 result = runner.invoke(cli, ["coord", "gc", "--json"])
1075 assert result.exit_code == 0
1076 data = json.loads(result.output.strip())
1077 assert "dry_run" in data
1078 assert "total_removed" in data
1079
1080 def test_gc_no_traceback_on_empty_repo(self, repo: pathlib.Path) -> None:
1081 with patch(_REQUIRE_REPO, return_value=repo):
1082 result = runner.invoke(cli, ["coord", "gc"])
1083 assert "Traceback" not in result.output
1084 assert result.exit_code == 0
1085
1086
1087 # ===========================================================================
1088 # 10. CLI INPUT VALIDATION HARDENING
1089 # ===========================================================================
1090
1091
1092 class TestCoordSecurityInputValidation:
1093 """Boundary tests for all size-limited and range-checked CLI inputs."""
1094
1095 def test_push_owner_at_max_length_accepted(self, repo: pathlib.Path) -> None:
1096 owner = "a" * _MAX_OWNER_LEN
1097 with patch(_PUSH_TARGET, return_value={"inserted": 0, "skipped": 0}), \
1098 patch(_REQUIRE_REPO, return_value=repo), \
1099 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
1100 result = runner.invoke(cli, _push_args(owner=owner))
1101 # Should not exit 1 due to length check
1102 assert "too long" not in result.output
1103
1104 def test_push_owner_one_over_max_length_rejected_before_io(
1105 self, repo: pathlib.Path
1106 ) -> None:
1107 owner = "a" * (_MAX_OWNER_LEN + 1)
1108 gather_called = []
1109
1110 def fake_gather(root: pathlib.Path, kinds: list[str]) -> list[MsgpackDict]:
1111 gather_called.append(True)
1112 return []
1113
1114 with patch("muse.cli.commands.coord_sync._gather_local_records", fake_gather), \
1115 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
1116 result = runner.invoke(cli, _push_args(owner=owner))
1117 assert result.exit_code == 1
1118 assert not gather_called, "File I/O was performed before length check!"
1119
1120 def test_push_slug_at_max_length_accepted(self, repo: pathlib.Path) -> None:
1121 slug = "s" * _MAX_SLUG_LEN
1122 with patch(_PUSH_TARGET, return_value={"inserted": 0, "skipped": 0}), \
1123 patch(_REQUIRE_REPO, return_value=repo), \
1124 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
1125 result = runner.invoke(cli, _push_args(slug=slug))
1126 assert "too long" not in result.output
1127
1128 def test_push_slug_one_over_max_length_rejected_before_io(
1129 self, repo: pathlib.Path
1130 ) -> None:
1131 slug = "s" * (_MAX_SLUG_LEN + 1)
1132 gather_called = []
1133
1134 def fake_gather(root: pathlib.Path, kinds: list[str]) -> list[MsgpackDict]:
1135 gather_called.append(True)
1136 return []
1137
1138 with patch("muse.cli.commands.coord_sync._gather_local_records", fake_gather), \
1139 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
1140 result = runner.invoke(cli, _push_args(slug=slug))
1141 assert result.exit_code == 1
1142 assert not gather_called, "File I/O was performed before slug length check!"
1143
1144 def test_pull_since_id_max_python_int_accepted(self, repo: pathlib.Path) -> None:
1145 """sys.maxsize as --since-id must not overflow or crash."""
1146 with patch(_PULL_TARGET, return_value={"records": [], "cursor": 0}), \
1147 patch(_REQUIRE_REPO, return_value=repo), \
1148 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
1149 result = runner.invoke(
1150 cli,
1151 ["coord", "sync", "pull",
1152 "--hub", "http://localhost:10003",
1153 "--owner", "gabriel",
1154 "--slug", "linux",
1155 "--since-id", str(sys.maxsize)],
1156 )
1157 # Should not crash with overflow — may exit 0 or 1 depending on argparse int handling
1158 assert "Traceback" not in result.output
1159
1160 def test_pull_limit_zero_rejected(self, repo: pathlib.Path) -> None:
1161 result = runner.invoke(cli, _pull_args() + ["--limit", "0"])
1162 assert result.exit_code == 1
1163
1164 def test_pull_limit_at_max_accepted(self, repo: pathlib.Path) -> None:
1165 with patch(_PULL_TARGET, return_value={"records": [], "cursor": 0}), \
1166 patch(_REQUIRE_REPO, return_value=repo), \
1167 patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
1168 result = runner.invoke(cli, _pull_args() + ["--limit", str(_MAX_PULL_LIMIT)])
1169 assert result.exit_code == 0
1170
1171 def test_pull_limit_one_over_max_rejected(self, repo: pathlib.Path) -> None:
1172 result = runner.invoke(
1173 cli, _pull_args() + ["--limit", str(_MAX_PULL_LIMIT + 1)]
1174 )
1175 assert result.exit_code == 1
1176
1177 def test_pull_since_id_negative_rejected(self, repo: pathlib.Path) -> None:
1178 result = runner.invoke(cli, _pull_args() + ["--since-id", "-1"])
1179 assert result.exit_code == 1
1180
1181 def test_no_traceback_on_any_validation_failure(self, repo: pathlib.Path) -> None:
1182 """All input validation failures must emit clean messages, never tracebacks."""
1183 bad_invocations = [
1184 _push_args(owner="a" * (_MAX_OWNER_LEN + 1)),
1185 _push_args(slug="s" * (_MAX_SLUG_LEN + 1)),
1186 _pull_args() + ["--since-id", "-99"],
1187 _pull_args() + ["--limit", "0"],
1188 _pull_args() + ["--limit", str(_MAX_PULL_LIMIT + 1)],
1189 ]
1190 for args in bad_invocations:
1191 with patch(_RESOLVE_HUB, return_value=("http://localhost:10003", "tok")):
1192 result = runner.invoke(cli, args)
1193 assert result.exit_code == 1, f"Expected exit 1 for: {args}"
1194 assert "Traceback" not in result.output, (
1195 f"Traceback leaked for args: {args}\nOutput: {result.output}"
1196 )
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