Files
Hy-Memory/plugins/hermes/tests/test_ca_trust.py
de4292ed4e feat(hermes): add managed native prefetch provider (#143)
* feat: add native Hermes memory provider

* feat(hermes): support scoped private CA trust

* feat(hermes): bound write timeout separately from reads

* fix(hermes): support truststore scoped CA snapshots

* feat(hermes): add managed prefetch provider

Co-authored-by: kindong <727777477@qq.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: kindong <727777477@qq.com>
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 16:51:15 +08:00

412 lines
15 KiB
Python

"""Scoped CA configuration and real ephemeral localhost TLS regressions."""
import hashlib
import json
import os
import shutil
import socket
import ssl
import subprocess
import threading
import pytest
import hermes_hy_memory.provider as provider_module
from hermes_hy_memory import HyMemoryProvider
from hermes_hy_memory.provider import ProviderError, _deadline_handlers
@pytest.fixture(autouse=True)
def configuration(monkeypatch):
for key in os.environ:
if key.startswith("HY_MEMORY_"):
monkeypatch.delenv(key)
monkeypatch.setenv("HY_MEMORY_USER_ID", "owner")
monkeypatch.setenv("HY_MEMORY_AGENT_ID", "agent")
@pytest.fixture(scope="module")
def certificates(tmp_path_factory):
if not shutil.which("openssl"):
pytest.skip("openssl required for ephemeral TLS certificates")
directory = tmp_path_factory.mktemp("tls")
pairs = []
for name in ("localhost", "other"):
cert, key = directory / (name + ".crt"), directory / (name + ".key")
subprocess.run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
str(key),
"-out",
str(cert),
"-days",
"1",
"-subj",
"/CN=localhost",
"-addext",
"subjectAltName=DNS:localhost",
"-addext",
"basicConstraints=critical,CA:TRUE",
],
check=True,
capture_output=True,
)
pairs.append((cert, key))
return pairs
@pytest.mark.parametrize("kind", ["missing", "malformed", "permission", "empty"])
def test_invalid_ca_is_sanitized(tmp_path, monkeypatch, kind):
path = tmp_path / "PRIVATE-path.pem"
if kind == "malformed":
path.write_text("PRIVATE malformed PEM")
if kind == "permission":
def denied(*args, **kwargs):
raise PermissionError("PRIVATE path or PEM")
monkeypatch.setattr(ssl, "create_default_context", denied)
monkeypatch.setenv("HY_MEMORY_CA_FILE", "" if kind == "empty" else str(path))
provider = HyMemoryProvider()
assert not provider.is_available()
assert provider._config_error == "invalid_configuration"
with pytest.raises(ProviderError, match="^invalid_configuration$"):
provider.initialize("session")
assert "PRIVATE" not in json.dumps(provider.identity_signature())
assert "PRIVATE" not in provider.unavailable_reason()
def test_context_is_scoped_and_initialized_once(certificates, monkeypatch):
cert = certificates[0][0]
original = ssl.create_default_context
calls = []
def create(*args, **kwargs):
calls.append(kwargs)
return original(*args, **kwargs)
monkeypatch.setattr(ssl, "create_default_context", create)
monkeypatch.setenv("HY_MEMORY_CA_FILE", str(cert))
monkeypatch.setenv("SSL_CERT_FILE", "/does/not/exist/ambient.pem")
global_factory = ssl._create_default_https_context
provider = HyMemoryProvider()
assert provider.is_available()
context = provider._ssl_context
assert context.check_hostname and context.verify_mode == ssl.CERT_REQUIRED
assert _deadline_handlers(100, context)[1]._context is context
provider.is_available()
provider.identity_signature()
assert calls == [{"cadata": cert.read_text()}]
assert ssl._create_default_https_context is global_factory
expected = hashlib.sha256(ssl.PEM_cert_to_DER_cert(cert.read_text())).hexdigest()
assert provider.identity_signature()["hy_memory_ca_sha256"] == expected
assert str(cert) not in json.dumps(provider.identity_signature())
def test_rotation_at_same_path_changes_identity(certificates, tmp_path, monkeypatch):
path = tmp_path / "ca.pem"
monkeypatch.setenv("HY_MEMORY_CA_FILE", str(path))
path.write_bytes(certificates[0][0].read_bytes())
first = HyMemoryProvider()
path.write_bytes(certificates[1][0].read_bytes())
second = HyMemoryProvider()
assert first.is_available() and second.is_available()
assert first.identity_signature() != second.identity_signature()
def test_unset_retains_default_handler_and_official_https(monkeypatch):
def forbidden(*args, **kwargs):
raise AssertionError("unset CA must retain existing trust setup")
monkeypatch.setattr(ssl, "create_default_context", forbidden)
monkeypatch.setenv("HY_MEMORY_URL", "https://example.com")
provider = HyMemoryProvider()
assert provider.is_available()
assert provider._ssl_context is None
context = _deadline_handlers(100)[1]._context
assert context.check_hostname and context.verify_mode == ssl.CERT_REQUIRED
@pytest.mark.parametrize("mode", ["trusted", "untrusted", "hostname-mismatch"])
def test_real_localhost_tls(certificates, monkeypatch, mode):
cert, key = certificates[0]
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(cert, key)
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen(1)
listener.settimeout(2)
failures = []
def serve():
try:
conn, _ = listener.accept()
with conn, context.wrap_socket(conn, server_side=True) as tls:
tls.settimeout(2)
# urllib can send headers and the POST body in separate TLS
# records. Drain the complete request before closing, or the
# unread body can cause a reset instead of a clean response.
max_headers, max_body = 65536, 1024 * 1024
request = bytearray()
while b"\r\n\r\n" not in request:
chunk = tls.recv(min(4096, max_headers + 1 - len(request)))
if not chunk:
raise ValueError("truncated HTTP headers")
request.extend(chunk)
if len(request) > max_headers and b"\r\n\r\n" not in request:
raise ValueError("oversized HTTP headers")
headers, received_body = bytes(request).split(b"\r\n\r\n", 1)
if len(headers) + 4 > max_headers:
raise ValueError("oversized HTTP headers")
lengths = []
for line in headers.split(b"\r\n")[1:]:
name, separator, value = line.partition(b":")
if not separator:
raise ValueError("malformed HTTP header")
if name.lower() == b"transfer-encoding":
raise ValueError("unsupported transfer encoding")
if name.lower() == b"content-length":
lengths.append(value.strip())
if len(lengths) != 1 or not lengths[0].isdigit():
raise ValueError("invalid Content-Length")
length = int(lengths[0])
if length > max_body or len(received_body) > length:
raise ValueError("oversized HTTP body")
remaining = length - len(received_body)
while remaining:
chunk = tls.recv(min(4096, remaining))
if not chunk:
raise ValueError("truncated HTTP body")
remaining -= len(chunk)
body = b'{"memories":[]}'
tls.sendall(
b"HTTP/1.1 200 OK\r\nContent-Length: "
+ str(len(body)).encode()
+ b"\r\n\r\n"
+ body
)
except ssl.SSLError:
pass # Expected peer rejection in negative cases.
except Exception as exc:
failures.append(type(exc).__name__)
host = "127.0.0.1" if mode == "hostname-mismatch" else "localhost"
monkeypatch.setenv("HY_MEMORY_URL", f"https://{host}:{listener.getsockname()[1]}")
if mode != "untrusted":
monkeypatch.setenv("HY_MEMORY_CA_FILE", str(cert))
provider = HyMemoryProvider()
assert provider.is_available()
provider.initialize("tls-session")
worker = threading.Thread(target=serve)
worker.start()
try:
result = json.loads(
provider.handle_tool_call("hy_memory_search", {"query": "q"})
)
if mode == "trusted":
assert result == {"success": True, "memories": []}
else:
assert result == {
"success": False,
"error": "transport_or_response_failure",
}
finally:
provider.shutdown()
listener.close()
worker.join(3)
assert not worker.is_alive()
assert not failures
@pytest.mark.parametrize("scoped", [False, True])
def test_request_handler_context_and_legacy_argument_count(
certificates, monkeypatch, scoped
):
if scoped:
monkeypatch.setenv("HY_MEMORY_CA_FILE", str(certificates[0][0]))
provider = HyMemoryProvider()
calls = []
def handlers(*args):
calls.append(args)
return ()
class Response:
status = 200
body = b'{"memories":[]}'
def __enter__(self):
return self
def __exit__(self, *args):
pass
def read1(self, size):
body, self.body = self.body, b""
return body
class Opener:
def open(self, request, timeout):
return Response()
monkeypatch.setattr(provider_module, "_deadline_handlers", handlers)
monkeypatch.setattr("urllib.request.build_opener", lambda *args: Opener())
assert provider._request("search", {}) == {"memories": []}
assert len(calls) == 1
assert len(calls[0]) == (2 if scoped else 1)
if scoped:
assert calls[0][1] is provider._ssl_context
def test_context_without_ca_inspection(certificates, monkeypatch):
original = ssl.create_default_context
calls = []
class Context:
def __init__(self, context):
self.context = context
def get_ca_certs(self, *args, **kwargs):
calls.append(True)
raise NotImplementedError
@property
def check_hostname(self):
return self.context.check_hostname
@check_hostname.setter
def check_hostname(self, value):
self.context.check_hostname = value
@property
def verify_mode(self):
return self.context.verify_mode
@verify_mode.setter
def verify_mode(self, value):
self.context.verify_mode = value
monkeypatch.setattr(
ssl, "create_default_context", lambda **kw: Context(original(**kw))
)
monkeypatch.setenv("HY_MEMORY_CA_FILE", str(certificates[0][0]))
provider = HyMemoryProvider()
assert provider.is_available()
assert provider._ssl_context.check_hostname
assert provider._ssl_context.verify_mode == ssl.CERT_REQUIRED
assert calls == []
def test_single_read_rotation_snapshot(certificates, tmp_path, monkeypatch):
path = tmp_path / "ca.pem"
snapshot = certificates[0][0].read_bytes()
path.write_bytes(snapshot)
original = ssl.create_default_context
original_open = open
reads = []
def tracked_open(file, *args, **kwargs):
if file == str(path):
reads.append(file)
return original_open(file, *args, **kwargs)
def create(**kwargs):
path.write_bytes(certificates[1][0].read_bytes())
assert kwargs == {"cadata": snapshot.decode("ascii")}
return original(**kwargs)
monkeypatch.setattr("builtins.open", tracked_open)
monkeypatch.setattr(ssl, "create_default_context", create)
monkeypatch.setenv("HY_MEMORY_CA_FILE", str(path))
provider = HyMemoryProvider()
assert provider.is_available()
assert reads == [str(path)]
assert (
provider.identity_signature()["hy_memory_ca_sha256"]
== hashlib.sha256(
ssl.PEM_cert_to_DER_cert(snapshot.decode("ascii"))
).hexdigest()
)
def test_bundle_order_whitespace_and_der_identity(certificates, tmp_path, monkeypatch):
path = tmp_path / "ca.pem"
monkeypatch.setenv("HY_MEMORY_CA_FILE", str(path))
first, second = [pair[0].read_text() for pair in certificates]
path.write_text(first + second)
bundle = HyMemoryProvider()
path.write_text("\n" + second + "\n" + first + "\n")
reordered = HyMemoryProvider()
assert bundle.is_available() and reordered.is_available()
assert bundle.identity_signature() == reordered.identity_signature()
path.write_text(first)
pem = HyMemoryProvider()
path.write_bytes(ssl.PEM_cert_to_DER_cert(first))
der = HyMemoryProvider()
assert pem.is_available() and der.is_available()
assert pem.identity_signature() == der.identity_signature()
@pytest.mark.parametrize(
"contents",
[
b"",
b"x" * 1048577,
b"\xffPRIVATE",
b"-----BEGIN CERTIFICATE-----\nPRIVATE\n-----END CERTIFICATE-----",
],
)
def test_invalid_ca_snapshot(tmp_path, monkeypatch, contents):
path = tmp_path / "PRIVATE.pem"
path.write_bytes(contents)
monkeypatch.setenv("HY_MEMORY_CA_FILE", str(path))
provider = HyMemoryProvider()
assert not provider.is_available()
assert provider._config_error == "invalid_configuration"
with pytest.raises(ProviderError, match="^invalid_configuration$"):
provider.initialize("session")
def test_real_truststore_context(certificates, monkeypatch):
import importlib.util
if importlib.util.find_spec("truststore") is None:
return # Optional dependency; deterministic regression always runs above.
import truststore
calls = []
def forbidden(*args, **kwargs):
calls.append(True)
raise NotImplementedError
monkeypatch.setattr(truststore.SSLContext, "get_ca_certs", forbidden)
monkeypatch.setattr(ssl, "SSLContext", truststore.SSLContext)
monkeypatch.setenv("HY_MEMORY_CA_FILE", str(certificates[0][0]))
provider = HyMemoryProvider()
assert provider.is_available()
assert isinstance(provider._ssl_context, truststore.SSLContext)
assert provider._ssl_context.check_hostname
assert provider._ssl_context.verify_mode == ssl.CERT_REQUIRED
assert calls == []
def test_maximum_size_bundle_is_accepted(certificates, tmp_path, monkeypatch):
snapshot = certificates[0][0].read_bytes()
path = tmp_path / "ca.pem"
path.write_bytes(snapshot + b"\n" * (1048576 - len(snapshot)))
monkeypatch.setenv("HY_MEMORY_CA_FILE", str(path))
provider = HyMemoryProvider()
assert provider.is_available()
assert (
provider.identity_signature()["hy_memory_ca_sha256"]
== hashlib.sha256(
ssl.PEM_cert_to_DER_cert(snapshot.decode("ascii"))
).hexdigest()
)