codebase-memory-mcp 0.9.0__tar.gz → 0.9.1rc1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -66,3 +66,7 @@ graph-ui/.npm-cache-local/
66
66
  # Python bytecode from tests/windows/ harness
67
67
  __pycache__/
68
68
  *.pyc
69
+
70
+ # Local soak-leg outputs (uploaded as CI artifacts, never committed)
71
+ soak-results/
72
+ soak-results-query-leak/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebase-memory-mcp
3
- Version: 0.9.0
3
+ Version: 0.9.1rc1
4
4
  Summary: Fast code intelligence engine for AI coding agents — single static binary MCP server
5
5
  Project-URL: Homepage, https://github.com/DeusData/codebase-memory-mcp
6
6
  Project-URL: Repository, https://github.com/DeusData/codebase-memory-mcp
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codebase-memory-mcp"
7
- version = "0.9.0"
7
+ version = "0.9.1-rc.1"
8
8
  description = "Fast code intelligence engine for AI coding agents — single static binary MCP server"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -0,0 +1,535 @@
1
+ """Downloads codebase-memory-mcp on first run, then runs its native entry point."""
2
+
3
+ import hashlib
4
+ import os
5
+ import platform
6
+ import shutil
7
+ import stat
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ REPO = "DeusData/codebase-memory-mcp"
18
+ _WINDOWS_BINARY_NAME = "codebase-memory-mcp.exe"
19
+ _WINDOWS_ARCHIVE_NAMES = (
20
+ _WINDOWS_BINARY_NAME,
21
+ "LICENSE",
22
+ "install.ps1",
23
+ "THIRD_PARTY_NOTICES.md",
24
+ )
25
+
26
+ # Security: only permit https fetches. urllib's default handlers accept
27
+ # file://, ftp://, and custom schemes — a redirect or tainted URL source
28
+ # could otherwise turn a download into an arbitrary-local-file read.
29
+ _ALLOWED_SCHEMES = frozenset({"https"})
30
+ _MAX_REDIRECTS = 5
31
+ _NETWORK_TIMEOUT_SECONDS = 120
32
+ _CANDIDATE_TIMEOUT_SECONDS = 15
33
+ _MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024
34
+ _REDIRECT_CODES = frozenset({301, 302, 303, 307, 308})
35
+
36
+
37
+ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
38
+ """Expose redirects to _download_https for explicit per-hop validation."""
39
+
40
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
41
+ return None
42
+
43
+
44
+ _HTTPS_OPENER = urllib.request.build_opener(_NoRedirectHandler())
45
+
46
+
47
+ def _validate_url_scheme(url: str) -> None:
48
+ """Reject non-https URLs before any network fetch."""
49
+ parsed = urllib.parse.urlparse(url)
50
+ if (
51
+ parsed.scheme not in _ALLOWED_SCHEMES
52
+ or not parsed.hostname
53
+ or parsed.username is not None
54
+ or parsed.password is not None
55
+ ):
56
+ sys.exit(
57
+ f"codebase-memory-mcp: refusing to fetch invalid, credentialed, or "
58
+ f"non-https URL: {url}"
59
+ )
60
+
61
+
62
+ def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
63
+ """Download through a bounded HTTPS-only opener into dest."""
64
+ current_url = url
65
+ for redirect_count in range(_MAX_REDIRECTS + 1):
66
+ _validate_url_scheme(current_url)
67
+ request = urllib.request.Request(
68
+ current_url, headers={"User-Agent": "codebase-memory-mcp-installer"}
69
+ )
70
+ try:
71
+ response = _HTTPS_OPENER.open(
72
+ request, timeout=_NETWORK_TIMEOUT_SECONDS
73
+ )
74
+ except urllib.error.HTTPError as exc:
75
+ if exc.code not in _REDIRECT_CODES:
76
+ raise
77
+ location = exc.headers.get("Location")
78
+ exc.close()
79
+ if not location:
80
+ raise RuntimeError(f"redirect has no Location: {current_url}")
81
+ if redirect_count == _MAX_REDIRECTS:
82
+ raise RuntimeError("too many redirects")
83
+ current_url = urllib.parse.urljoin(current_url, location)
84
+ _validate_url_scheme(current_url)
85
+ continue
86
+
87
+ with response:
88
+ _validate_url_scheme(response.geturl())
89
+ deadline = time.monotonic() + _NETWORK_TIMEOUT_SECONDS
90
+ total = 0
91
+ with open(dest, "wb") as out:
92
+ while True:
93
+ if time.monotonic() >= deadline:
94
+ raise TimeoutError(
95
+ f"download hop timed out: {current_url}"
96
+ )
97
+ chunk = response.read(65536)
98
+ if not chunk:
99
+ return
100
+ total += len(chunk)
101
+ if max_bytes and total > max_bytes:
102
+ raise RuntimeError(
103
+ f"download exceeds the {max_bytes}-byte safety limit"
104
+ )
105
+ out.write(chunk)
106
+
107
+ raise RuntimeError("too many redirects")
108
+
109
+
110
+ def _verify_candidate(path: Path) -> None:
111
+ """Require a staged native candidate to execute successfully."""
112
+ try:
113
+ subprocess.run(
114
+ [str(path), "--version"],
115
+ stdin=subprocess.DEVNULL,
116
+ stdout=subprocess.DEVNULL,
117
+ stderr=subprocess.DEVNULL,
118
+ check=True,
119
+ timeout=_CANDIDATE_TIMEOUT_SECONDS,
120
+ )
121
+ except (OSError, subprocess.SubprocessError) as exc:
122
+ raise RuntimeError(f"downloaded binary failed to run: {exc}") from exc
123
+
124
+
125
+ def _file_sha256(path: Path) -> str:
126
+ digest = hashlib.sha256()
127
+ with path.open("rb") as source:
128
+ for chunk in iter(lambda: source.read(65536), b""):
129
+ digest.update(chunk)
130
+ return digest.hexdigest()
131
+
132
+
133
+ def _files_equal_sha256(left: Path, right: Path) -> bool:
134
+ try:
135
+ return (
136
+ left.is_file()
137
+ and right.is_file()
138
+ and left.stat().st_size == right.stat().st_size
139
+ and _file_sha256(left) == _file_sha256(right)
140
+ )
141
+ except OSError:
142
+ return False
143
+
144
+
145
+ def _safe_extract_tar(tf, dest: str) -> None:
146
+ """Extract a tarfile to dest, rejecting path-traversal entries.
147
+
148
+ Uses the tarfile data filter on Python >=3.12 (PEP 706), falls back to
149
+ manual per-member path validation on older Pythons. Mitigates the
150
+ classic tar-slip / Zip Slip vulnerability (CWE-22).
151
+ """
152
+ if hasattr(tf, "extraction_filter") or sys.version_info >= (3, 12):
153
+ tf.extractall(dest, filter="data")
154
+ return
155
+
156
+ dest_abs = os.path.abspath(dest)
157
+ for member in tf.getmembers():
158
+ if member.issym() or member.islnk():
159
+ sys.exit(
160
+ f"codebase-memory-mcp: refusing unsafe tar entry "
161
+ f"(link: {member.name!r})"
162
+ )
163
+ member_abs = os.path.abspath(os.path.join(dest_abs, member.name))
164
+ if not (member_abs == dest_abs or member_abs.startswith(dest_abs + os.sep)):
165
+ sys.exit(
166
+ f"codebase-memory-mcp: refusing unsafe tar entry "
167
+ f"(escapes dest: {member.name!r})"
168
+ )
169
+ tf.extractall(dest)
170
+
171
+
172
+ def _safe_extract_zip(zf, dest: str, archive_names=(), extract_names=()) -> None:
173
+ """Extract a zipfile after validating its Windows-style namespace.
174
+
175
+ Windows treats paths case-insensitively. Rejecting duplicate/case-conflicting
176
+ members before extraction prevents archive order from selecting the binary
177
+ that a portable package-manager shim eventually executes.
178
+ """
179
+ dest_abs = os.path.abspath(dest)
180
+ seen = set()
181
+ required = tuple(archive_names)
182
+ required_set = set(required)
183
+ found = set()
184
+ for info in zf.infolist():
185
+ raw_name = info.filename
186
+ name = raw_name.replace("\\", "/")
187
+ segments_name = name[:-1] if name.endswith("/") else name
188
+ segments = segments_name.split("/")
189
+ if (
190
+ not segments_name
191
+ or name.startswith("/")
192
+ or ":" in name
193
+ or any(segment in ("", ".", "..") for segment in segments)
194
+ or any(segment.endswith((".", " ")) for segment in segments)
195
+ ):
196
+ sys.exit(
197
+ f"codebase-memory-mcp: refusing unsafe zip entry "
198
+ f"(invalid path: {raw_name!r})"
199
+ )
200
+ folded = segments_name.casefold()
201
+ if folded in seen:
202
+ sys.exit(
203
+ f"codebase-memory-mcp: refusing unsafe zip entry "
204
+ f"(duplicate or case conflict: {raw_name!r})"
205
+ )
206
+ seen.add(folded)
207
+ if name not in required_set or info.is_dir():
208
+ sys.exit(
209
+ f"codebase-memory-mcp: archive must contain only the exact "
210
+ f"root files: {', '.join(required)}"
211
+ )
212
+ found.add(name)
213
+ member_abs = os.path.abspath(os.path.join(dest_abs, *segments))
214
+ if not (member_abs == dest_abs or member_abs.startswith(dest_abs + os.sep)):
215
+ sys.exit(
216
+ f"codebase-memory-mcp: refusing unsafe zip entry "
217
+ f"(escapes dest: {raw_name!r})"
218
+ )
219
+ missing = required_set - found
220
+ if missing or len(seen) != len(required):
221
+ sys.exit(
222
+ f"codebase-memory-mcp: archive must contain exactly one of each "
223
+ f"required root file: {', '.join(required)}"
224
+ )
225
+
226
+ # Extract only the two validated executable files. This avoids relying on
227
+ # platform-specific zip path rewriting and always creates regular files.
228
+ for name in extract_names:
229
+ target = os.path.join(dest_abs, name)
230
+ with zf.open(name) as source, open(target, "xb") as output:
231
+ shutil.copyfileobj(source, output)
232
+
233
+
234
+ def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None:
235
+ """Verify SHA256 checksum against checksums.txt from the release."""
236
+ url = f"https://github.com/{REPO}/releases/download/v{version}/checksums.txt"
237
+ tmp_path = None
238
+ try:
239
+ _validate_url_scheme(url)
240
+ with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
241
+ tmp_path = tmp.name
242
+ _download_https(url, tmp_path, _MAX_CHECKSUM_MANIFEST_BYTES)
243
+ expected = None
244
+ with open(tmp_path, encoding="utf-8") as f:
245
+ for line in f:
246
+ fields = line.split()
247
+ if len(fields) < 2 or fields[1] not in (
248
+ archive_name,
249
+ f"*{archive_name}",
250
+ ):
251
+ continue
252
+ digest = fields[0].lower()
253
+ if len(digest) != 64 or any(
254
+ ch not in "0123456789abcdef" for ch in digest
255
+ ):
256
+ sys.exit(
257
+ f"codebase-memory-mcp: invalid SHA256 checksum for "
258
+ f"{archive_name}"
259
+ )
260
+ if expected is not None and expected != digest:
261
+ sys.exit(
262
+ f"codebase-memory-mcp: conflicting SHA256 checksums for "
263
+ f"{archive_name}"
264
+ )
265
+ expected = digest
266
+ if expected is None:
267
+ sys.exit(
268
+ f"codebase-memory-mcp: no checksum for {archive_name} in checksums.txt"
269
+ )
270
+ h = hashlib.sha256()
271
+ with open(archive_path, "rb") as af:
272
+ for chunk in iter(lambda: af.read(65536), b""):
273
+ h.update(chunk)
274
+ actual = h.hexdigest()
275
+ if expected != actual:
276
+ sys.exit(
277
+ f"codebase-memory-mcp: CHECKSUM MISMATCH for {archive_name}\n"
278
+ f" expected: {expected}\n"
279
+ f" actual: {actual}"
280
+ )
281
+ print("codebase-memory-mcp: checksum verified.", file=sys.stderr)
282
+ except SystemExit:
283
+ raise
284
+ except Exception as exc:
285
+ sys.exit(f"codebase-memory-mcp: checksum verification failed: {exc}")
286
+ finally:
287
+ if tmp_path is not None:
288
+ try:
289
+ os.unlink(tmp_path)
290
+ except Exception:
291
+ pass
292
+
293
+
294
+ def _version() -> str:
295
+ try:
296
+ from importlib.metadata import version
297
+ return version("codebase-memory-mcp")
298
+ except Exception:
299
+ return "0.8.1"
300
+
301
+
302
+ def _os_name() -> str:
303
+ p = sys.platform
304
+ if p == "linux":
305
+ return "linux"
306
+ if p == "darwin":
307
+ return "darwin"
308
+ if p == "win32":
309
+ return "windows"
310
+ sys.exit(f"codebase-memory-mcp: unsupported platform: {p}")
311
+
312
+
313
+ def _arch() -> str:
314
+ m = platform.machine().lower()
315
+ if m in ("arm64", "aarch64"):
316
+ return "arm64"
317
+ if m in ("x86_64", "amd64"):
318
+ return "amd64"
319
+ sys.exit(f"codebase-memory-mcp: unsupported architecture: {m}")
320
+
321
+
322
+ def _cache_dir() -> Path:
323
+ if sys.platform == "win32":
324
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
325
+ elif sys.platform == "darwin":
326
+ base = Path.home() / "Library" / "Caches"
327
+ else:
328
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
329
+ return base / "codebase-memory-mcp"
330
+
331
+
332
+ def _bin_path(version: str) -> Path:
333
+ # One binary per platform: the cached file is the executed file.
334
+ name = (
335
+ _WINDOWS_BINARY_NAME
336
+ if sys.platform == "win32"
337
+ else "codebase-memory-mcp"
338
+ )
339
+ return _cache_dir() / version / name
340
+
341
+
342
+ def _execution_path(binary: Path, target_platform: str) -> Path:
343
+ """The cached binary is executed directly on every platform."""
344
+ return binary
345
+
346
+
347
+ def _windows_binary_ready(version: str) -> bool:
348
+ binary = _cache_dir() / version / _WINDOWS_BINARY_NAME
349
+ if not binary.is_file():
350
+ return False
351
+ try:
352
+ _verify_candidate(binary)
353
+ except RuntimeError:
354
+ return False
355
+ return True
356
+
357
+
358
+ def _download(version: str) -> Path:
359
+ os_name = _os_name()
360
+ arch = _arch()
361
+ ext = "zip" if os_name == "windows" else "tar.gz"
362
+ # Linux ships a fully-static "-portable" build; the standard linux binary
363
+ # dynamically links glibc 2.38+ and fails on older distros. macOS/Windows
364
+ # have no such variant. Keep in sync with install.sh / install.js / cli.c.
365
+ variant = "-portable" if os_name == "linux" else ""
366
+ # Opt into the UI build (embedded graph visualization) with CBM_VARIANT=ui.
367
+ # Default is the standard (headless) build. Mirrors install.sh --ui.
368
+ ui = "ui-" if os.environ.get("CBM_VARIANT", "").lower() == "ui" else ""
369
+ archive = f"codebase-memory-mcp-{ui}{os_name}-{arch}{variant}.{ext}"
370
+ url = f"https://github.com/{REPO}/releases/download/v{version}/{archive}"
371
+ _validate_url_scheme(url)
372
+
373
+ dest = _bin_path(version)
374
+ dest.parent.mkdir(parents=True, exist_ok=True)
375
+
376
+ print(
377
+ f"codebase-memory-mcp: downloading v{version} for {os_name}/{arch}...",
378
+ file=sys.stderr,
379
+ )
380
+
381
+ with tempfile.TemporaryDirectory() as tmp:
382
+ tmp_archive = os.path.join(tmp, f"cbm.{ext}")
383
+ try:
384
+ _download_https(url, tmp_archive)
385
+ except (OSError, RuntimeError, urllib.error.URLError) as e:
386
+ sys.exit(
387
+ f"codebase-memory-mcp: download failed ({e})\n"
388
+ f"URL: {url}\n"
389
+ f"See https://github.com/{REPO}/releases for available versions."
390
+ )
391
+
392
+ _verify_checksum(tmp_archive, archive, version)
393
+
394
+ bin_name = (
395
+ _WINDOWS_BINARY_NAME if os_name == "windows" else "codebase-memory-mcp"
396
+ )
397
+ extraction_names = (bin_name,)
398
+ cache_names = extraction_names
399
+ publish_names = cache_names
400
+ if ext == "tar.gz":
401
+ import tarfile
402
+ with tarfile.open(tmp_archive) as tf:
403
+ _safe_extract_tar(tf, tmp)
404
+ else:
405
+ import zipfile
406
+ with zipfile.ZipFile(tmp_archive) as zf:
407
+ _safe_extract_zip(
408
+ zf,
409
+ tmp,
410
+ _WINDOWS_ARCHIVE_NAMES,
411
+ extraction_names,
412
+ )
413
+
414
+ extracted_paths = {}
415
+ for name in extraction_names:
416
+ extracted_path = Path(tmp) / name
417
+ if not extracted_path.is_file():
418
+ sys.exit(
419
+ f"codebase-memory-mcp: required binary not found after "
420
+ f"extraction: {name}"
421
+ )
422
+ current = extracted_path.stat().st_mode
423
+ extracted_path.chmod(
424
+ current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
425
+ )
426
+ extracted_paths[name] = extracted_path
427
+ try:
428
+ _verify_candidate(extracted_paths[bin_name])
429
+ except RuntimeError as exc:
430
+ sys.exit(f"codebase-memory-mcp: {exc}")
431
+
432
+ staged_paths = {}
433
+ try:
434
+ for name in cache_names:
435
+ staged_suffix = ".tmp.exe" if sys.platform == "win32" else ".tmp"
436
+ with tempfile.NamedTemporaryFile(
437
+ dir=str(dest.parent),
438
+ prefix=f".{name}.",
439
+ suffix=staged_suffix,
440
+ delete=False,
441
+ ) as staged:
442
+ staged_path = Path(staged.name)
443
+ shutil.copy2(extracted_paths[name], staged_path)
444
+ staged_mode = staged_path.stat().st_mode
445
+ staged_path.chmod(
446
+ staged_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
447
+ )
448
+ staged_paths[name] = staged_path
449
+
450
+ if os_name != "windows":
451
+ _verify_candidate(staged_paths[bin_name])
452
+ for name in publish_names:
453
+ target = dest.parent / name
454
+ try:
455
+ os.replace(staged_paths[name], target)
456
+ staged_paths.pop(name)
457
+ except OSError as publish_error:
458
+ # Never unlink a destination here: it may belong to a
459
+ # concurrent contender. A collision remains eligible only
460
+ # when its bytes match this authenticated stage; pair
461
+ # execution is checked after payload is published last.
462
+ if not _files_equal_sha256(staged_paths[name], target):
463
+ raise publish_error
464
+ if os_name == "windows":
465
+ _verify_candidate(dest.parent / _WINDOWS_BINARY_NAME)
466
+ else:
467
+ _verify_candidate(dest)
468
+ except RuntimeError as exc:
469
+ sys.exit(f"codebase-memory-mcp: {exc}")
470
+ finally:
471
+ for staged_path in staged_paths.values():
472
+ try:
473
+ staged_path.unlink()
474
+ except OSError:
475
+ pass
476
+
477
+ return dest
478
+
479
+
480
+ def main() -> None:
481
+ version = _version()
482
+ bin_path = _bin_path(version)
483
+
484
+ cache_ready = (
485
+ _windows_binary_ready(version)
486
+ if sys.platform == "win32"
487
+ else bin_path.is_file()
488
+ )
489
+ if not cache_ready:
490
+ bin_path = _download(version)
491
+
492
+ execution_path = _execution_path(bin_path, sys.platform)
493
+
494
+ # args is a list (not a shell string), so exec/subprocess treat each
495
+ # element as a discrete argv entry — no shell interpretation, no
496
+ # injection vector. sys.argv forwarding is the whole point of this
497
+ # shim, so tainted-input suppression is intentional.
498
+ args = [str(execution_path)] + sys.argv[1:]
499
+
500
+ if sys.platform != "win32":
501
+ os.execv(str(execution_path), args) # noqa: S606 — list form, no shell
502
+ else:
503
+ result = subprocess.run(args) # noqa: S603 — list form, no shell=True
504
+ mutation = _portable_mutation_action(sys.argv[1:])
505
+ if result.returncode != 0 and mutation is not None:
506
+ package_command = (
507
+ "python -m pip install --upgrade codebase-memory-mcp"
508
+ if mutation == "update"
509
+ else "python -m pip uninstall codebase-memory-mcp"
510
+ )
511
+ print(
512
+ f'This PyPI Windows copy is portable. Use "{package_command}" '
513
+ f'for package maintenance, or run "codebase-memory-mcp '
514
+ f'install --yes" once to create a managed launcher with '
515
+ f'coordinated self-update/uninstall.',
516
+ file=sys.stderr,
517
+ )
518
+ sys.exit(result.returncode)
519
+
520
+
521
+ def _portable_mutation_action(args):
522
+ for argument in args:
523
+ if argument in (
524
+ "cli",
525
+ "hook-augment",
526
+ "config",
527
+ "install",
528
+ "--help",
529
+ "-h",
530
+ "--version",
531
+ ):
532
+ return None
533
+ if argument in ("update", "uninstall"):
534
+ return argument
535
+ return None
@@ -0,0 +1,36 @@
1
+ import sys
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+
6
+ PACKAGE_ROOT = Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(PACKAGE_ROOT / "src"))
8
+
9
+ from codebase_memory_mcp import _cli # noqa: E402
10
+
11
+
12
+ class BinarySelectionTests(unittest.TestCase):
13
+ """One binary ships per platform: the cached file is the executed file."""
14
+
15
+ def test_windows_executes_the_cached_binary(self):
16
+ binary = Path("cache") / "0.8.1" / _cli._WINDOWS_BINARY_NAME
17
+
18
+ self.assertEqual(_cli._execution_path(binary, "win32"), binary)
19
+
20
+ def test_non_windows_executes_the_cached_binary(self):
21
+ binary = Path("cache") / "0.8.1" / "codebase-memory-mcp"
22
+
23
+ self.assertEqual(_cli._execution_path(binary, "linux"), binary)
24
+
25
+ def test_portable_mutation_guidance_classification_is_preserved(self):
26
+ self.assertEqual(_cli._portable_mutation_action(["update"]), "update")
27
+ self.assertEqual(
28
+ _cli._portable_mutation_action(["uninstall", "--yes"]),
29
+ "uninstall",
30
+ )
31
+ self.assertIsNone(_cli._portable_mutation_action(["install", "--yes"]))
32
+ self.assertIsNone(_cli._portable_mutation_action(["cli", "update"]))
33
+
34
+
35
+ if __name__ == "__main__":
36
+ unittest.main()
@@ -1,224 +0,0 @@
1
- """Downloads the codebase-memory-mcp binary on first run, then exec's it."""
2
-
3
- import hashlib
4
- import os
5
- import sys
6
- import platform
7
- import stat
8
- import shutil
9
- import tempfile
10
- import urllib.request
11
- import urllib.error
12
- import urllib.parse
13
- from pathlib import Path
14
-
15
- REPO = "DeusData/codebase-memory-mcp"
16
-
17
- # Security: only permit https fetches. urllib's default handlers accept
18
- # file://, ftp://, and custom schemes — a redirect or tainted URL source
19
- # could otherwise turn a download into an arbitrary-local-file read.
20
- _ALLOWED_SCHEMES = frozenset({"https"})
21
-
22
-
23
- def _validate_url_scheme(url: str) -> None:
24
- """Reject non-https URLs before any network fetch."""
25
- scheme = urllib.parse.urlparse(url).scheme
26
- if scheme not in _ALLOWED_SCHEMES:
27
- sys.exit(
28
- f"codebase-memory-mcp: refusing to fetch non-https URL "
29
- f"(scheme={scheme!r}): {url}"
30
- )
31
-
32
-
33
- def _safe_extract_tar(tf, dest: str) -> None:
34
- """Extract a tarfile to dest, rejecting path-traversal entries.
35
-
36
- Uses the tarfile data filter on Python >=3.12 (PEP 706), falls back to
37
- manual per-member path validation on older Pythons. Mitigates the
38
- classic tar-slip / Zip Slip vulnerability (CWE-22).
39
- """
40
- if hasattr(tf, "extraction_filter") or sys.version_info >= (3, 12):
41
- tf.extractall(dest, filter="data")
42
- return
43
-
44
- dest_abs = os.path.abspath(dest)
45
- for member in tf.getmembers():
46
- if member.issym() or member.islnk():
47
- sys.exit(
48
- f"codebase-memory-mcp: refusing unsafe tar entry "
49
- f"(link: {member.name!r})"
50
- )
51
- member_abs = os.path.abspath(os.path.join(dest_abs, member.name))
52
- if not (member_abs == dest_abs or member_abs.startswith(dest_abs + os.sep)):
53
- sys.exit(
54
- f"codebase-memory-mcp: refusing unsafe tar entry "
55
- f"(escapes dest: {member.name!r})"
56
- )
57
- tf.extractall(dest)
58
-
59
-
60
- def _safe_extract_zip(zf, dest: str) -> None:
61
- """Extract a zipfile to dest, rejecting path-traversal entries."""
62
- dest_abs = os.path.abspath(dest)
63
- for name in zf.namelist():
64
- member_abs = os.path.abspath(os.path.join(dest_abs, name))
65
- if not (member_abs == dest_abs or member_abs.startswith(dest_abs + os.sep)):
66
- sys.exit(
67
- f"codebase-memory-mcp: refusing unsafe zip entry "
68
- f"(escapes dest: {name!r})"
69
- )
70
- zf.extractall(dest)
71
-
72
-
73
- def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None:
74
- """Verify SHA256 checksum against checksums.txt from the release."""
75
- url = f"https://github.com/{REPO}/releases/download/v{version}/checksums.txt"
76
- try:
77
- _validate_url_scheme(url)
78
- with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
79
- tmp_path = tmp.name
80
- urllib.request.urlretrieve(url, tmp_path) # noqa: S310 — scheme validated above
81
- with open(tmp_path) as f:
82
- for line in f:
83
- if archive_name in line:
84
- expected = line.split()[0]
85
- h = hashlib.sha256()
86
- with open(archive_path, "rb") as af:
87
- for chunk in iter(lambda: af.read(65536), b""):
88
- h.update(chunk)
89
- actual = h.hexdigest()
90
- if expected != actual:
91
- sys.exit(
92
- f"codebase-memory-mcp: CHECKSUM MISMATCH for {archive_name}\n"
93
- f" expected: {expected}\n"
94
- f" actual: {actual}"
95
- )
96
- print("codebase-memory-mcp: checksum verified.", file=sys.stderr)
97
- break
98
- except SystemExit:
99
- raise
100
- except Exception:
101
- pass # Non-fatal: checksum unavailable
102
- finally:
103
- try:
104
- os.unlink(tmp_path)
105
- except Exception:
106
- pass
107
-
108
-
109
- def _version() -> str:
110
- try:
111
- from importlib.metadata import version
112
- return version("codebase-memory-mcp")
113
- except Exception:
114
- return "0.8.1"
115
-
116
-
117
- def _os_name() -> str:
118
- p = sys.platform
119
- if p == "linux":
120
- return "linux"
121
- if p == "darwin":
122
- return "darwin"
123
- if p == "win32":
124
- return "windows"
125
- sys.exit(f"codebase-memory-mcp: unsupported platform: {p}")
126
-
127
-
128
- def _arch() -> str:
129
- m = platform.machine().lower()
130
- if m in ("arm64", "aarch64"):
131
- return "arm64"
132
- if m in ("x86_64", "amd64"):
133
- return "amd64"
134
- sys.exit(f"codebase-memory-mcp: unsupported architecture: {m}")
135
-
136
-
137
- def _cache_dir() -> Path:
138
- if sys.platform == "win32":
139
- base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
140
- elif sys.platform == "darwin":
141
- base = Path.home() / "Library" / "Caches"
142
- else:
143
- base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
144
- return base / "codebase-memory-mcp"
145
-
146
-
147
- def _bin_path(version: str) -> Path:
148
- name = "codebase-memory-mcp.exe" if sys.platform == "win32" else "codebase-memory-mcp"
149
- return _cache_dir() / version / name
150
-
151
-
152
- def _download(version: str) -> Path:
153
- os_name = _os_name()
154
- arch = _arch()
155
- ext = "zip" if os_name == "windows" else "tar.gz"
156
- # Linux ships a fully-static "-portable" build; the standard linux binary
157
- # dynamically links glibc 2.38+ and fails on older distros. macOS/Windows
158
- # have no such variant. Keep in sync with install.sh / install.js / cli.c.
159
- variant = "-portable" if os_name == "linux" else ""
160
- archive = f"codebase-memory-mcp-{os_name}-{arch}{variant}.{ext}"
161
- url = f"https://github.com/{REPO}/releases/download/v{version}/{archive}"
162
- _validate_url_scheme(url)
163
-
164
- dest = _bin_path(version)
165
- dest.parent.mkdir(parents=True, exist_ok=True)
166
-
167
- print(
168
- f"codebase-memory-mcp: downloading v{version} for {os_name}/{arch}...",
169
- file=sys.stderr,
170
- )
171
-
172
- with tempfile.TemporaryDirectory() as tmp:
173
- tmp_archive = os.path.join(tmp, f"cbm.{ext}")
174
- try:
175
- urllib.request.urlretrieve(url, tmp_archive) # noqa: S310 — scheme validated above
176
- except urllib.error.HTTPError as e:
177
- sys.exit(
178
- f"codebase-memory-mcp: download failed ({e})\n"
179
- f"URL: {url}\n"
180
- f"See https://github.com/{REPO}/releases for available versions."
181
- )
182
-
183
- _verify_checksum(tmp_archive, archive, version)
184
-
185
- if ext == "tar.gz":
186
- import tarfile
187
- with tarfile.open(tmp_archive) as tf:
188
- _safe_extract_tar(tf, tmp)
189
- else:
190
- import zipfile
191
- with zipfile.ZipFile(tmp_archive) as zf:
192
- _safe_extract_zip(zf, tmp)
193
-
194
- bin_name = "codebase-memory-mcp.exe" if os_name == "windows" else "codebase-memory-mcp"
195
- extracted = os.path.join(tmp, bin_name)
196
- if not os.path.exists(extracted):
197
- sys.exit("codebase-memory-mcp: binary not found after extraction")
198
-
199
- shutil.copy2(extracted, dest)
200
- current = dest.stat().st_mode
201
- dest.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
202
-
203
- return dest
204
-
205
-
206
- def main() -> None:
207
- version = _version()
208
- bin_path = _bin_path(version)
209
-
210
- if not bin_path.exists():
211
- bin_path = _download(version)
212
-
213
- # args is a list (not a shell string), so exec/subprocess treat each
214
- # element as a discrete argv entry — no shell interpretation, no
215
- # injection vector. sys.argv forwarding is the whole point of this
216
- # shim, so tainted-input suppression is intentional.
217
- args = [str(bin_path)] + sys.argv[1:]
218
-
219
- if sys.platform != "win32":
220
- os.execv(str(bin_path), args) # noqa: S606 — list form, no shell
221
- else:
222
- import subprocess
223
- result = subprocess.run(args) # noqa: S603 — list form, no shell=True
224
- sys.exit(result.returncode)