mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
feat: coordinate concurrent CBM sessions
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
a688d85cc8
commit
0e00ef5702
112 files changed
+63161
-2288
No files matched your search
@@ -13,22 +13,33 @@ import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
repo = "DeusData/codebase-memory-mcp"
|
||||
version = "0.8.1"
|
||||
|
||||
maxRedirects = 5
|
||||
requestTimeout = 2 * time.Minute
|
||||
connectTimeout = 15 * time.Second
|
||||
responseHeaderTimeout = 30 * time.Second
|
||||
candidateTimeout = 15 * time.Second
|
||||
maxChecksumManifestSize = 1024 * 1024
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -117,7 +128,11 @@ func download(dest string) error {
|
||||
ext = "zip"
|
||||
}
|
||||
|
||||
archive := fmt.Sprintf("codebase-memory-mcp-%s-%s.%s", platform, arch, ext)
|
||||
portable := ""
|
||||
if platform == "linux" {
|
||||
portable = "-portable"
|
||||
}
|
||||
archive := fmt.Sprintf("codebase-memory-mcp-%s-%s%s.%s", platform, arch, portable, ext)
|
||||
url := fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", repo, version, archive)
|
||||
checksumURL := fmt.Sprintf("https://github.com/%s/releases/download/v%s/checksums.txt", repo, version)
|
||||
|
||||
@@ -134,13 +149,18 @@ func download(dest string) error {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Verify checksum if available (non-fatal if checksums.txt unreachable)
|
||||
if checksums, err := fetchChecksums(checksumURL); err == nil {
|
||||
if expected, ok := checksums[archive]; ok {
|
||||
if err := verifyChecksum(archivePath, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// A release binary is executable input, so checksum verification is a
|
||||
// mandatory precondition rather than a best-effort warning.
|
||||
checksums, err := fetchChecksums(checksumURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checksum manifest unavailable: %w", err)
|
||||
}
|
||||
expected, ok := checksums[archive]
|
||||
if !ok {
|
||||
return fmt.Errorf("checksum manifest has no entry for %s", archive)
|
||||
}
|
||||
if err := verifyChecksum(archivePath, expected); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
binName := "codebase-memory-mcp"
|
||||
@@ -157,25 +177,29 @@ func download(dest string) error {
|
||||
return fmt.Errorf("extraction failed: %w", err)
|
||||
}
|
||||
}
|
||||
extracted := filepath.Join(tmp, binName)
|
||||
if err := os.Chmod(extracted, 0755); err != nil {
|
||||
return fmt.Errorf("could not set candidate permissions: %w", err)
|
||||
}
|
||||
if err := verifyCandidate(extracted); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
||||
return fmt.Errorf("could not create cache dir: %w", err)
|
||||
}
|
||||
|
||||
if err := copyFile(filepath.Join(tmp, binName), dest); err != nil {
|
||||
if err := installCandidateAtomically(extracted, dest); err != nil {
|
||||
return fmt.Errorf("could not install binary: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(dest, 0755); err != nil {
|
||||
return fmt.Errorf("could not set permissions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateURLScheme rejects non-https URLs before any fetch (defense-in-depth).
|
||||
func validateURLScheme(rawURL string) error {
|
||||
if !strings.HasPrefix(rawURL, "https://") {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
||||
return fmt.Errorf("refusing non-https URL: %s", rawURL)
|
||||
}
|
||||
return nil
|
||||
@@ -183,11 +207,20 @@ func validateURLScheme(rawURL string) error {
|
||||
|
||||
// httpsOnlyClient returns an HTTP client that rejects non-HTTPS redirects.
|
||||
var httpsOnlyClient = &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{Timeout: connectTimeout}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: connectTimeout,
|
||||
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("refusing non-https redirect to %s", req.URL)
|
||||
if err := validateURLScheme(req.URL.String()); err != nil {
|
||||
return fmt.Errorf("refusing unsafe redirect: %w", err)
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
if len(via) > maxRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
@@ -210,9 +243,12 @@ func httpGet(rawURL, dest string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
_, copyErr := io.Copy(f, resp.Body)
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func fetchChecksums(url string) (map[string]string, error) {
|
||||
@@ -227,21 +263,45 @@ func fetchChecksums(url string) (map[string]string, error) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxChecksumManifestSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > maxChecksumManifestSize {
|
||||
return nil, fmt.Errorf("checksums.txt exceeds the 1 MiB safety limit")
|
||||
}
|
||||
result := make(map[string]string)
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) == 2 {
|
||||
result[parts[1]] = parts[0]
|
||||
name := parts[1]
|
||||
if strings.HasPrefix(name, "*") {
|
||||
name = name[1:]
|
||||
}
|
||||
digest := strings.ToLower(parts[0])
|
||||
if len(digest) != sha256.Size*2 {
|
||||
return nil, fmt.Errorf("invalid SHA-256 checksum for %s", name)
|
||||
}
|
||||
if _, err := hex.DecodeString(digest); err != nil {
|
||||
return nil, fmt.Errorf("invalid SHA-256 checksum for %s: %w", name, err)
|
||||
}
|
||||
if prior, exists := result[name]; exists && prior != digest {
|
||||
return nil, fmt.Errorf("conflicting SHA-256 checksums for %s", name)
|
||||
}
|
||||
result[name] = digest
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func verifyChecksum(path, expected string) error {
|
||||
expected = strings.ToLower(expected)
|
||||
if len(expected) != sha256.Size*2 {
|
||||
return fmt.Errorf("invalid SHA-256 checksum length")
|
||||
}
|
||||
if _, err := hex.DecodeString(expected); err != nil {
|
||||
return fmt.Errorf("invalid SHA-256 checksum: %w", err)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -316,19 +376,60 @@ func extractZip(archivePath, destDir, targetFile string) error {
|
||||
return fmt.Errorf("%s not found in archive", targetFile)
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
func verifyCandidate(path string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), candidateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, path, "--version")
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = io.Discard
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("downloaded binary verification timed out: %w", ctx.Err())
|
||||
}
|
||||
return fmt.Errorf("downloaded binary failed to run: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installCandidateAtomically(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
pattern := "." + filepath.Base(dst) + ".*.tmp"
|
||||
if runtime.GOOS == "windows" {
|
||||
pattern += ".exe"
|
||||
}
|
||||
out, err := os.CreateTemp(filepath.Dir(dst), pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
staged := out.Name()
|
||||
defer os.Remove(staged)
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Chmod(0755); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := verifyCandidate(staged); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(staged, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execBinary(bin string, args []string) error {
|
||||
|
||||
+171
-41
@@ -9,10 +9,15 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { pipeline } = require('stream');
|
||||
|
||||
const REPO = 'DeusData/codebase-memory-mcp';
|
||||
const VERSION = require('./package.json').version;
|
||||
const BIN_DIR = path.join(__dirname, 'bin');
|
||||
const MAX_REDIRECTS = 5;
|
||||
const DOWNLOAD_HOP_TIMEOUT_MS = 120_000;
|
||||
const CANDIDATE_TIMEOUT_MS = 15_000;
|
||||
const MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024;
|
||||
|
||||
function getPlatform() {
|
||||
switch (process.platform) {
|
||||
@@ -31,36 +36,145 @@ function getArch() {
|
||||
}
|
||||
}
|
||||
|
||||
// Security: only follow HTTPS URLs (defense-in-depth).
|
||||
function validateUrl(url) {
|
||||
if (!url.startsWith('https://')) {
|
||||
throw new Error(`Refusing non-HTTPS URL: ${url}`);
|
||||
// Security: only follow HTTPS URLs (defense-in-depth). Parse the URL instead
|
||||
// of relying on a string prefix so every redirect is checked unambiguously.
|
||||
function validateUrl(rawUrl) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid download URL ${rawUrl}: ${err.message}`);
|
||||
}
|
||||
if (parsed.protocol !== 'https:' || !parsed.hostname || parsed.username || parsed.password) {
|
||||
throw new Error(`Refusing non-HTTPS or credentialed URL: ${rawUrl}`);
|
||||
}
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
function downloadHop(url, dest, maxBytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let response = null;
|
||||
let timer = null;
|
||||
|
||||
function finish(err, result) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
}
|
||||
|
||||
const req = https.get(url, (res) => {
|
||||
response = res;
|
||||
res.on('error', (err) => finish(err));
|
||||
const redirectCodes = new Set([301, 302, 303, 307, 308]);
|
||||
if (redirectCodes.has(res.statusCode)) {
|
||||
const location = res.headers.location;
|
||||
if (!location) {
|
||||
res.destroy();
|
||||
finish(new Error(`Redirect with no location for ${url}`));
|
||||
return;
|
||||
}
|
||||
let next;
|
||||
try {
|
||||
next = validateUrl(new URL(location, url).href);
|
||||
} catch (err) {
|
||||
res.destroy();
|
||||
finish(err);
|
||||
return;
|
||||
}
|
||||
res.destroy();
|
||||
finish(null, { redirect: next });
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
res.destroy();
|
||||
finish(new Error(`HTTP ${res.statusCode} for ${url}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let received = 0;
|
||||
res.on('data', (chunk) => {
|
||||
received += chunk.length;
|
||||
if (maxBytes && received > maxBytes) {
|
||||
res.destroy(new Error(`Download exceeds the ${maxBytes}-byte safety limit`));
|
||||
}
|
||||
});
|
||||
const file = fs.createWriteStream(dest, { flags: 'w' });
|
||||
pipeline(res, file, (err) => {
|
||||
finish(err, { redirect: null });
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => finish(err));
|
||||
timer = setTimeout(() => {
|
||||
const err = new Error(`Download hop timed out after ${DOWNLOAD_HOP_TIMEOUT_MS} ms: ${url}`);
|
||||
req.destroy(err);
|
||||
if (response) response.destroy(err);
|
||||
}, DOWNLOAD_HOP_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
async function download(rawUrl, dest, maxBytes = 0) {
|
||||
let url = validateUrl(rawUrl);
|
||||
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
||||
const result = await downloadHop(url, dest, maxBytes);
|
||||
if (!result.redirect) return;
|
||||
if (redirects === MAX_REDIRECTS) throw new Error('Too many redirects');
|
||||
url = result.redirect;
|
||||
}
|
||||
}
|
||||
|
||||
function download(url, dest) {
|
||||
validateUrl(url);
|
||||
return new Promise((resolve, reject) => {
|
||||
function follow(u, depth) {
|
||||
if (depth > 5) return reject(new Error('Too many redirects'));
|
||||
validateUrl(u);
|
||||
https.get(u, (res) => {
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
const loc = res.headers.location;
|
||||
if (!loc) return reject(new Error('Redirect with no location'));
|
||||
const next = loc.startsWith('/') ? new URL(loc, u).href : loc;
|
||||
return follow(next, depth + 1);
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
|
||||
}
|
||||
const file = fs.createWriteStream(dest);
|
||||
res.pipe(file);
|
||||
file.on('finish', () => file.close(resolve));
|
||||
file.on('error', reject);
|
||||
}).on('error', reject);
|
||||
function parseExpectedChecksum(manifest, archiveName) {
|
||||
let expected = null;
|
||||
for (const line of manifest.split('\n')) {
|
||||
const fields = line.trim().split(/\s+/);
|
||||
if (fields.length < 2) continue;
|
||||
if (fields[1] !== archiveName && fields[1] !== `*${archiveName}`) continue;
|
||||
|
||||
const digest = fields[0].toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(digest)) {
|
||||
throw new Error(`Invalid SHA-256 checksum for ${archiveName}`);
|
||||
}
|
||||
follow(url, 0);
|
||||
if (expected !== null && expected !== digest) {
|
||||
throw new Error(`Conflicting SHA-256 checksums for ${archiveName}`);
|
||||
}
|
||||
expected = digest;
|
||||
}
|
||||
if (expected === null) {
|
||||
throw new Error(`No checksum for ${archiveName} in checksums.txt`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function verifyCandidate(candidatePath) {
|
||||
execFileSync(candidatePath, ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: CANDIDATE_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
function extractZipOnWindows(archivePath, destPath) {
|
||||
// -EncodedCommand is a constant program. Paths travel only through the child
|
||||
// environment and are consumed with -LiteralPath, so PowerShell never parses
|
||||
// user/TEMP path bytes as source code or wildcard syntax.
|
||||
const script = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
'Expand-Archive -LiteralPath $env:CBM_NPM_ARCHIVE_PATH ' +
|
||||
'-DestinationPath $env:CBM_NPM_DEST_PATH -Force',
|
||||
].join('; ');
|
||||
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
||||
execFileSync('powershell', [
|
||||
'-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded,
|
||||
], {
|
||||
env: {
|
||||
...process.env,
|
||||
CBM_NPM_ARCHIVE_PATH: archivePath,
|
||||
CBM_NPM_DEST_PATH: destPath,
|
||||
},
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,11 +183,14 @@ async function verifyChecksum(archivePath, archiveName) {
|
||||
const url = `https://github.com/${REPO}/releases/download/v${VERSION}/checksums.txt`;
|
||||
const tmpChecksums = archivePath + '.checksums';
|
||||
try {
|
||||
await download(url, tmpChecksums);
|
||||
const lines = fs.readFileSync(tmpChecksums, 'utf-8').split('\n');
|
||||
const match = lines.find((l) => l.includes(archiveName));
|
||||
if (!match) return; // checksum line not found — non-fatal
|
||||
const expected = match.split(/\s+/)[0];
|
||||
await download(url, tmpChecksums, MAX_CHECKSUM_MANIFEST_BYTES);
|
||||
const manifestSize = fs.statSync(tmpChecksums).size;
|
||||
if (manifestSize > MAX_CHECKSUM_MANIFEST_BYTES) {
|
||||
throw new Error('checksums.txt exceeds the 1 MiB safety limit');
|
||||
}
|
||||
const expected = parseExpectedChecksum(
|
||||
fs.readFileSync(tmpChecksums, 'utf-8'), archiveName,
|
||||
);
|
||||
const actual = crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(archivePath))
|
||||
@@ -84,9 +201,6 @@ async function verifyChecksum(archivePath, archiveName) {
|
||||
);
|
||||
}
|
||||
process.stdout.write('codebase-memory-mcp: checksum verified.\n');
|
||||
} catch (err) {
|
||||
if (err.message.startsWith('Checksum mismatch')) throw err;
|
||||
// Non-fatal: checksum unavailable (network issue, pre-release, etc.)
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpChecksums); } catch (_) { /* ignore */ }
|
||||
}
|
||||
@@ -100,7 +214,12 @@ async function main() {
|
||||
const binPath = path.join(BIN_DIR, binName);
|
||||
|
||||
if (fs.existsSync(binPath)) {
|
||||
return; // already installed, nothing to do
|
||||
try {
|
||||
verifyCandidate(binPath);
|
||||
return; // already installed and runnable, nothing to do
|
||||
} catch (_) {
|
||||
fs.rmSync(binPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
@@ -129,10 +248,7 @@ async function main() {
|
||||
if (ext === 'tar.gz') {
|
||||
execFileSync('tar', ['-xzf', tmpArchive, '-C', tmpDir, '--no-same-owner']);
|
||||
} else {
|
||||
execFileSync('powershell', [
|
||||
'-NoProfile', '-Command',
|
||||
`Expand-Archive -Path '${tmpArchive}' -DestinationPath '${tmpDir}' -Force`,
|
||||
]);
|
||||
extractZipOnWindows(tmpArchive, tmpDir);
|
||||
}
|
||||
|
||||
// Validate extracted path doesn't escape tmpDir (tar-slip defense).
|
||||
@@ -146,8 +262,22 @@ async function main() {
|
||||
throw new Error(`Binary not found after extraction at ${extracted}`);
|
||||
}
|
||||
|
||||
fs.copyFileSync(extracted, binPath);
|
||||
fs.chmodSync(binPath, 0o755);
|
||||
fs.chmodSync(extracted, 0o755);
|
||||
verifyCandidate(extracted);
|
||||
|
||||
const stagedSuffix = platform === 'windows' ? '.tmp.exe' : '.tmp';
|
||||
const staged = path.join(
|
||||
BIN_DIR,
|
||||
`.${binName}.${process.pid}.${crypto.randomBytes(8).toString('hex')}${stagedSuffix}`,
|
||||
);
|
||||
try {
|
||||
fs.copyFileSync(extracted, staged, fs.constants.COPYFILE_EXCL);
|
||||
fs.chmodSync(staged, 0o755);
|
||||
verifyCandidate(staged);
|
||||
fs.renameSync(staged, binPath);
|
||||
} finally {
|
||||
try { fs.unlinkSync(staged); } catch (_) { /* renamed or never created */ }
|
||||
}
|
||||
|
||||
process.stdout.write('codebase-memory-mcp: ready.\n');
|
||||
} finally {
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import stat
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO = "DeusData/codebase-memory-mcp"
|
||||
@@ -18,18 +20,101 @@ REPO = "DeusData/codebase-memory-mcp"
|
||||
# file://, ftp://, and custom schemes — a redirect or tainted URL source
|
||||
# could otherwise turn a download into an arbitrary-local-file read.
|
||||
_ALLOWED_SCHEMES = frozenset({"https"})
|
||||
_MAX_REDIRECTS = 5
|
||||
_NETWORK_TIMEOUT_SECONDS = 120
|
||||
_CANDIDATE_TIMEOUT_SECONDS = 15
|
||||
_MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024
|
||||
_REDIRECT_CODES = frozenset({301, 302, 303, 307, 308})
|
||||
|
||||
|
||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Expose redirects to _download_https for explicit per-hop validation."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
_HTTPS_OPENER = urllib.request.build_opener(_NoRedirectHandler())
|
||||
|
||||
|
||||
def _validate_url_scheme(url: str) -> None:
|
||||
"""Reject non-https URLs before any network fetch."""
|
||||
scheme = urllib.parse.urlparse(url).scheme
|
||||
if scheme not in _ALLOWED_SCHEMES:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if (
|
||||
parsed.scheme not in _ALLOWED_SCHEMES
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: refusing to fetch non-https URL "
|
||||
f"(scheme={scheme!r}): {url}"
|
||||
f"codebase-memory-mcp: refusing to fetch invalid, credentialed, or "
|
||||
f"non-https URL: {url}"
|
||||
)
|
||||
|
||||
|
||||
def _download_https(url: str, dest: str, max_bytes: int = 0) -> None:
|
||||
"""Download through a bounded HTTPS-only opener into dest."""
|
||||
current_url = url
|
||||
for redirect_count in range(_MAX_REDIRECTS + 1):
|
||||
_validate_url_scheme(current_url)
|
||||
request = urllib.request.Request(
|
||||
current_url, headers={"User-Agent": "codebase-memory-mcp-installer"}
|
||||
)
|
||||
try:
|
||||
response = _HTTPS_OPENER.open(
|
||||
request, timeout=_NETWORK_TIMEOUT_SECONDS
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code not in _REDIRECT_CODES:
|
||||
raise
|
||||
location = exc.headers.get("Location")
|
||||
exc.close()
|
||||
if not location:
|
||||
raise RuntimeError(f"redirect has no Location: {current_url}")
|
||||
if redirect_count == _MAX_REDIRECTS:
|
||||
raise RuntimeError("too many redirects")
|
||||
current_url = urllib.parse.urljoin(current_url, location)
|
||||
_validate_url_scheme(current_url)
|
||||
continue
|
||||
|
||||
with response:
|
||||
_validate_url_scheme(response.geturl())
|
||||
deadline = time.monotonic() + _NETWORK_TIMEOUT_SECONDS
|
||||
total = 0
|
||||
with open(dest, "wb") as out:
|
||||
while True:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(
|
||||
f"download hop timed out: {current_url}"
|
||||
)
|
||||
chunk = response.read(65536)
|
||||
if not chunk:
|
||||
return
|
||||
total += len(chunk)
|
||||
if max_bytes and total > max_bytes:
|
||||
raise RuntimeError(
|
||||
f"download exceeds the {max_bytes}-byte safety limit"
|
||||
)
|
||||
out.write(chunk)
|
||||
|
||||
raise RuntimeError("too many redirects")
|
||||
|
||||
|
||||
def _verify_candidate(path: Path) -> None:
|
||||
"""Require a staged native candidate to execute successfully."""
|
||||
try:
|
||||
subprocess.run(
|
||||
[str(path), "--version"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
timeout=_CANDIDATE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise RuntimeError(f"downloaded binary failed to run: {exc}") from exc
|
||||
|
||||
|
||||
def _safe_extract_tar(tf, dest: str) -> None:
|
||||
"""Extract a tarfile to dest, rejecting path-traversal entries.
|
||||
|
||||
@@ -73,37 +158,61 @@ def _safe_extract_zip(zf, dest: str) -> None:
|
||||
def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None:
|
||||
"""Verify SHA256 checksum against checksums.txt from the release."""
|
||||
url = f"https://github.com/{REPO}/releases/download/v{version}/checksums.txt"
|
||||
tmp_path = None
|
||||
try:
|
||||
_validate_url_scheme(url)
|
||||
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
urllib.request.urlretrieve(url, tmp_path) # noqa: S310 — scheme validated above
|
||||
with open(tmp_path) as f:
|
||||
_download_https(url, tmp_path, _MAX_CHECKSUM_MANIFEST_BYTES)
|
||||
expected = None
|
||||
with open(tmp_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if archive_name in line:
|
||||
expected = line.split()[0]
|
||||
h = hashlib.sha256()
|
||||
with open(archive_path, "rb") as af:
|
||||
for chunk in iter(lambda: af.read(65536), b""):
|
||||
h.update(chunk)
|
||||
actual = h.hexdigest()
|
||||
if expected != actual:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: CHECKSUM MISMATCH for {archive_name}\n"
|
||||
f" expected: {expected}\n"
|
||||
f" actual: {actual}"
|
||||
)
|
||||
print("codebase-memory-mcp: checksum verified.", file=sys.stderr)
|
||||
break
|
||||
fields = line.split()
|
||||
if len(fields) < 2 or fields[1] not in (
|
||||
archive_name,
|
||||
f"*{archive_name}",
|
||||
):
|
||||
continue
|
||||
digest = fields[0].lower()
|
||||
if len(digest) != 64 or any(
|
||||
ch not in "0123456789abcdef" for ch in digest
|
||||
):
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: invalid SHA256 checksum for "
|
||||
f"{archive_name}"
|
||||
)
|
||||
if expected is not None and expected != digest:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: conflicting SHA256 checksums for "
|
||||
f"{archive_name}"
|
||||
)
|
||||
expected = digest
|
||||
if expected is None:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: no checksum for {archive_name} in checksums.txt"
|
||||
)
|
||||
h = hashlib.sha256()
|
||||
with open(archive_path, "rb") as af:
|
||||
for chunk in iter(lambda: af.read(65536), b""):
|
||||
h.update(chunk)
|
||||
actual = h.hexdigest()
|
||||
if expected != actual:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: CHECKSUM MISMATCH for {archive_name}\n"
|
||||
f" expected: {expected}\n"
|
||||
f" actual: {actual}"
|
||||
)
|
||||
print("codebase-memory-mcp: checksum verified.", file=sys.stderr)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
pass # Non-fatal: checksum unavailable
|
||||
except Exception as exc:
|
||||
sys.exit(f"codebase-memory-mcp: checksum verification failed: {exc}")
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
if tmp_path is not None:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _version() -> str:
|
||||
@@ -175,8 +284,8 @@ def _download(version: str) -> Path:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_archive = os.path.join(tmp, f"cbm.{ext}")
|
||||
try:
|
||||
urllib.request.urlretrieve(url, tmp_archive) # noqa: S310 — scheme validated above
|
||||
except urllib.error.HTTPError as e:
|
||||
_download_https(url, tmp_archive)
|
||||
except (OSError, RuntimeError, urllib.error.URLError) as e:
|
||||
sys.exit(
|
||||
f"codebase-memory-mcp: download failed ({e})\n"
|
||||
f"URL: {url}\n"
|
||||
@@ -199,9 +308,40 @@ def _download(version: str) -> Path:
|
||||
if not os.path.exists(extracted):
|
||||
sys.exit("codebase-memory-mcp: binary not found after extraction")
|
||||
|
||||
shutil.copy2(extracted, dest)
|
||||
current = dest.stat().st_mode
|
||||
dest.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
extracted_path = Path(extracted)
|
||||
current = extracted_path.stat().st_mode
|
||||
extracted_path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
try:
|
||||
_verify_candidate(extracted_path)
|
||||
except RuntimeError as exc:
|
||||
sys.exit(f"codebase-memory-mcp: {exc}")
|
||||
|
||||
staged_path = None
|
||||
try:
|
||||
staged_suffix = ".tmp.exe" if sys.platform == "win32" else ".tmp"
|
||||
with tempfile.NamedTemporaryFile(
|
||||
dir=str(dest.parent),
|
||||
prefix=f".{dest.name}.",
|
||||
suffix=staged_suffix,
|
||||
delete=False,
|
||||
) as staged:
|
||||
staged_path = Path(staged.name)
|
||||
shutil.copy2(extracted_path, staged_path)
|
||||
staged_mode = staged_path.stat().st_mode
|
||||
staged_path.chmod(
|
||||
staged_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
||||
)
|
||||
_verify_candidate(staged_path)
|
||||
os.replace(staged_path, dest)
|
||||
staged_path = None
|
||||
except RuntimeError as exc:
|
||||
sys.exit(f"codebase-memory-mcp: {exc}")
|
||||
finally:
|
||||
if staged_path is not None:
|
||||
try:
|
||||
staged_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return dest
|
||||
|
||||
@@ -222,6 +362,5 @@ def main() -> None:
|
||||
if sys.platform != "win32":
|
||||
os.execv(str(bin_path), args) # noqa: S606 — list form, no shell
|
||||
else:
|
||||
import subprocess
|
||||
result = subprocess.run(args) # noqa: S603 — list form, no shell=True
|
||||
sys.exit(result.returncode)
|
||||
Reference in new issue
Block a user