diff --git a/resources/scripts/nap_install.py b/resources/scripts/nap_install.py index c9d1944..4fe669b 100644 --- a/resources/scripts/nap_install.py +++ b/resources/scripts/nap_install.py @@ -1042,8 +1042,12 @@ def print_gateway_help(topic=None): nap gateway schedule delete [ID | --schedule ID]""") return 0 if topic == "vault": - print("Usage: nap gateway vault [status|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]") - print(" status Show local gateway state and pending setup requests.") + print("Usage: nap gateway vault [status|setup|forget|process|rotate-secret] [--kind memory] [--all] [--project-id ID] [--vault-passphrase TEXT]") + print(" status Show local gateway state, memory secret provisioning, and pending setup requests.") + print(" setup Store the project memory secret in local encrypted storage for this worktree.") + print(" Prompts once for the project vault passphrase; the passphrase is never stored.") + print(" The CLI and MCP workers then load the secret automatically.") + print(" forget Remove the locally stored memory secret for this worktree.") print(" process Upload opaque client-wrapped key bundle records for backend-owned HashiCorp storage.") print(" rotate-secret Rotate the memory project secret from the gateway worker path.") return 0 @@ -1054,6 +1058,7 @@ def print_gateway_help(topic=None): print(" nap gateway repair Create dedicated gateway worker credentials.") print(" nap gateway terminal help Show terminal lifecycle commands.") print(" nap gateway schedule help Show schedule lifecycle commands.") + print(" nap gateway vault setup Store the E2E memory secret locally (prompts once).") print(" nap gateway vault help Show vault commands.") return 0 @@ -1308,6 +1313,10 @@ def mcp_runtime_probe(timeout_seconds=6): "verified": False, }, "remote_project_access": "not_tested", + "memory_secret": (local_payload or {}).get("memory_secret"), + "deprecations": [ + item.get("code") for item in (local_payload or {}).get("deprecations") or [] if isinstance(item, dict) + ], } except Exception as exc: return { @@ -1417,6 +1426,18 @@ def doctor_status(): else "inspect the failed_stage field and local project binding" ), }) + if probe.get("memory_secret") == "rotated": + setup.append({ + "code": "memory_secret_rotated", + "message": "the project memory secret was rotated after it was stored locally", + "next": "nap gateway vault setup", + }) + if "vault_passphrase_env_deprecated" in (probe.get("deprecations") or []): + setup.append({ + "code": "vault_passphrase_env_deprecated", + "message": "NAPSEER_VAULT_PASSPHRASE / NAPSEER_MASTER_PASSPHRASE are deprecated for agents; store the memory secret locally instead", + "next": "nap gateway vault setup", + }) status = "repair_required" if issues else ("setup_available" if setup else "ok") return { "status": status, diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 8f83b61..cf8e6a4 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -574,6 +574,25 @@ MEMORY_NODE_LEGACY_HKDF_INFO_PREFIX = "napseer-memory-local-mcp-v1" MEMORY_SECRET_CACHE = {} ACTIVE_MEMORY_SECRET_VERSIONS = {} PROJECT_VAULT_PASSPHRASE = None +# The unwrapped project memory secret is provisioned once per worktree into +# local encrypted storage (same envelope and 0600 local key as vault.json, in +# its own file so gateway vault rewrites never drop it). The passphrase itself +# is never stored. See /decisions/2026-09-29-memory-secret-local-vault. +MEMORY_SECRET_VAULT_PATH = AUTH_DIR / "memory-secrets.vault.json" +MEMORY_SECRET_VAULT_SCHEMA = "napseer.memory-secret-vault.v1" +MEMORY_SECRET_VAULT_MAX_ENTRIES = 16 +MEMORY_SECRET_OLDER_VERSION_LIMIT = 32 +MEMORY_SECRET_SETUP_COMMAND = "nap gateway vault setup" +MEMORY_SECRET_FORGET_COMMAND = "nap gateway vault forget" +LEGACY_VAULT_PASSPHRASE_ENV_VARS = ("NAPSEER_VAULT_PASSPHRASE", "NAPSEER_MASTER_PASSPHRASE") +MEMORY_SECRET_VAULT_LOCK = threading.RLock() +MEMORY_SECRET_VAULT_STATE = {"seen_generation": None, "seen_initialized": False, "summary_key": None, "summary": None} +# Non-secret per-process observations: project_id -> server active identity +# that differs from every locally provisioned identity. +MEMORY_SECRET_ROTATION_OBSERVED = {} +MEMORY_SECRET_DEPRECATION_LOGGED = set() +# True while serving MCP over stdio: never prompt, even if stdin is a TTY. +MCP_SERVING = False VAULT_SECRETS = {} VAULT_KEY = None @@ -786,11 +805,21 @@ def _derive_vault_key(passphrase, salt, iterations): return hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, iterations, dklen=32) +def interactive_prompt_allowed(): + """Only a CLI attached to a terminal may prompt; an MCP server never does.""" + if MCP_SERVING: + return False + try: + return sys.stdin.isatty() + except (AttributeError, ValueError, OSError): + return False + + def read_gateway_relay_passphrase(create=False): value = os.environ.get("NAPSEER_GATEWAY_PASSPHRASE") if value: return value - if not sys.stdin.isatty(): + if not interactive_prompt_allowed(): raise RuntimeError("NAPSEER_GATEWAY_PASSPHRASE is required in non-interactive mode") first = getpass.getpass("Create gateway relay passphrase: " if create else "Gateway relay passphrase: ") if create: @@ -804,7 +833,7 @@ def read_project_vault_passphrase(create=False): value = os.environ.get("NAPSEER_VAULT_PASSPHRASE") or os.environ.get("NAPSEER_MASTER_PASSPHRASE") if value: return value - if not sys.stdin.isatty(): + if not interactive_prompt_allowed(): raise RuntimeError("NAPSEER_VAULT_PASSPHRASE is required in non-interactive mode") first = getpass.getpass("Create project vault passphrase: " if create else "Project vault passphrase: ") if create: @@ -824,11 +853,36 @@ def read_local_vault_key(create=False): return value if not create: return None - AUTH_DIR.mkdir(parents=True, exist_ok=True) - chmod_best_effort(AUTH_DIR, 0o700) - value = base64.b64encode(secrets.token_bytes(32)).decode("ascii") - LOCAL_VAULT_KEY_PATH.write_text(value + "\n", encoding="utf-8") - chmod_best_effort(LOCAL_VAULT_KEY_PATH, 0o600) + path = LOCAL_VAULT_KEY_PATH + path.parent.mkdir(parents=True, exist_ok=True) + chmod_best_effort(path.parent, 0o700) + # 0600 from creation, and never replace a key another process published + # first: data already encrypted under it would become unreadable. + temporary = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(str(temporary), flags, 0o600) + try: + os.write(fd, (base64.b64encode(secrets.token_bytes(32)).decode("ascii") + "\n").encode("ascii")) + os.fsync(fd) + finally: + os.close(fd) + try: + os.link(temporary, path) + except FileExistsError: + pass + except OSError: + if not path.exists(): + os.replace(temporary, path) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + chmod_best_effort(path, 0o600) + value = path.read_text(encoding="utf-8").strip() + if not value: + raise RuntimeError("local vault key file is empty") return value @@ -1070,9 +1124,10 @@ def write_project_locator(project, *, base_url=None): return project_locator_status(desired) -def read_vault(passphrase, return_key=False): - envelope = json.loads(VAULT_PATH.read_text(encoding="utf-8")) - if envelope.get("schema") != VAULT_SCHEMA: +def read_vault(passphrase, return_key=False, path=None, schema=VAULT_SCHEMA): + path = VAULT_PATH if path is None else path + envelope = json.loads(path.read_text(encoding="utf-8")) + if envelope.get("schema") != schema: raise ValueError("local gateway vault open failed") if envelope.get("format") == "plaintext-local" or "payload" in envelope: raise ValueError("plaintext local gateway vault is unsupported; rerun gateway setup with a passphrase") @@ -1086,24 +1141,28 @@ def read_vault(passphrase, return_key=False): ciphertext = base64.b64decode(cipher["ciphertext"]) tag = base64.b64decode(cipher["tag"]) key = _derive_vault_key(passphrase, salt, iterations) - plaintext = _decrypt_vault_payload(key, nonce, ciphertext, tag, VAULT_SCHEMA.encode("utf-8")) + plaintext = _decrypt_vault_payload(key, nonce, ciphertext, tag, schema.encode("utf-8")) data = json.loads(plaintext.decode("utf-8")) if not isinstance(data, dict): raise ValueError("local gateway vault open failed") return (data, key) if return_key else data -def write_vault(passphrase, payload): +def write_vault(passphrase, payload, path=None, schema=VAULT_SCHEMA): + path = VAULT_PATH if path is None else path salt = secrets.token_bytes(16) key = _derive_vault_key(passphrase, salt, VAULT_KDF_ITERATIONS) plaintext = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") - nonce, ciphertext, tag = _encrypt_vault_payload(key, plaintext, VAULT_SCHEMA.encode("utf-8")) + nonce, ciphertext, tag = _encrypt_vault_payload(key, plaintext, schema.encode("utf-8")) AUTH_DIR.mkdir(parents=True, exist_ok=True) chmod_best_effort(AUTH_DIR, 0o700) - VAULT_PATH.write_text( - json.dumps( + # Temporary (0600, exclusive) + fsync + rename: a crash never leaves a + # truncated vault behind. + e2e_atomic_write( + path, + (json.dumps( { - "schema": VAULT_SCHEMA, + "schema": schema, "created_at": iso_now(), "kdf": { "name": "pbkdf2-sha256", @@ -1119,10 +1178,8 @@ def write_vault(passphrase, payload): }, indent=2, ) - + "\n", - encoding="utf-8", + + "\n").encode("utf-8"), ) - chmod_best_effort(VAULT_PATH, 0o600) def write_vault_with_key(key, payload): @@ -1497,9 +1554,22 @@ def gateway_status(): "telemetry_config_path": str(TELEMETRY_PATH), "telemetry_anonymous_id_present": bool(telemetry_state().get("anonymous_install_id")), **crypto_backend_status(), + **memory_secret_status_safe(DEFAULT_PROJECT_ID), } +def memory_secret_status_safe(project_id=None, **kwargs): + try: + return memory_secret_status(project_id, **kwargs) + except Exception as exc: + return { + "memory_secret": "not_provisioned", + "memory_secret_detail": "status_unavailable", + "memory_secret_error": safe_exception_payload(exc), + "memory_secret_next_action": MEMORY_SECRET_SETUP_COMMAND, + } + + def gateway_worker_capabilities(capabilities=None): current = capabilities if isinstance(capabilities, dict) else {} merged = dict(current) @@ -3690,7 +3760,7 @@ def project_bundle_passphrase(): value = os.environ.get("NAPSEER_VAULT_PASSPHRASE") or os.environ.get("NAPSEER_MASTER_PASSPHRASE") if value: return value - if sys.stdin.isatty(): + if interactive_prompt_allowed(): return set_project_vault_passphrase(read_project_vault_passphrase(create=False)) raise RuntimeError("project vault passphrase is required before handling project key bundles; set NAPSEER_VAULT_PASSPHRASE") @@ -3991,8 +4061,28 @@ def find_memory_secret_cache_slot(project_id, version): return None -def normalize_memory_secret_response(project_id, response, unavailable_message): - if response.get("secret_kind") != MEMORY_SECRET_KIND: +def memory_secret_identity(secret): + return ( + int(secret["version"]), + int(secret["wrapping_epoch"]), + int(secret["bundle_version"]), + int(secret["data_key_epoch"]), + ) + + +def memory_secret_identity_fields(identity): + version, wrapping_epoch, bundle_version, data_key_epoch = (int(value) for value in identity) + return { + "version": version, + "wrapping_epoch": wrapping_epoch, + "bundle_version": bundle_version, + "data_key_epoch": data_key_epoch, + } + + +def memory_secret_response_identity(project_id, response, unavailable_message): + """Validate the server's memory secret header; no passphrase is needed.""" + if not isinstance(response, dict) or response.get("secret_kind") != MEMORY_SECRET_KIND: raise RuntimeError(unavailable_message) version = int(response.get("version") or response.get("project_secret_version") or 0) if version <= 0: @@ -4012,6 +4102,12 @@ def normalize_memory_secret_response(project_id, response, unavailable_message): or int(wrapped_bundle.get("data_key_epoch") or 0) != data_key_epoch ): raise RuntimeError(unavailable_message) + return (version, wrapping_epoch, bundle_version, data_key_epoch), wrapped_bundle + + +def normalize_memory_secret_response(project_id, response, unavailable_message): + identity, wrapped_bundle = memory_secret_response_identity(project_id, response, unavailable_message) + version, wrapping_epoch, bundle_version, data_key_epoch = identity try: bundle = unwrap_project_key_bundle(wrapped_bundle) secret_bytes = project_data_key_from_bundle(bundle, MEMORY_SECRET_KIND, data_key_epoch) @@ -4030,10 +4126,374 @@ def normalize_memory_secret_response(project_id, response, unavailable_message): } +class MemorySecretUnavailable(SafeToolError): + """No provisioned local copy matches and no non-interactive passphrase exists. + + Carries only non-secret identity numbers and the provisioning next action. + """ + + def __init__(self, project_id, state, *, active_identity=None, version=None): + if state == "rotated": + code = "memory_secret_rotated" + message = ( + "The project memory secret was rotated after it was provisioned on this machine. " + f"Run `{MEMORY_SECRET_SETUP_COMMAND}` in a terminal to store the current version." + ) + else: + state = "not_provisioned" + code = "memory_secret_not_provisioned" + message = ( + "The project memory secret is not provisioned on this machine. " + f"Run `{MEMORY_SECRET_SETUP_COMMAND}` once in a terminal; the CLI and MCP workers then load it automatically." + ) + super().__init__(code, message) + self.project_id = str(project_id) + self.memory_secret = state + self.active_identity = tuple(int(value) for value in active_identity) if active_identity else None + self.details = {"memory_secret": state, "next_action": MEMORY_SECRET_SETUP_COMMAND} + if self.active_identity: + self.details["active_version"] = self.active_identity[0] + if version: + self.details["version"] = int(version) + + +class MemorySecretVaultUnreadable(Exception): + """The local memory secret vault exists but cannot be opened; fail closed.""" + + def __init__(self, reason): + super().__init__(f"local memory secret vault is unreadable ({reason})") + self.reason = reason + + +def legacy_vault_passphrase_env_names(): + return [name for name in LEGACY_VAULT_PASSPHRASE_ENV_VARS if os.environ.get(name)] + + +def memory_secret_passphrase_source(): + """Non-interactive passphrase source for unwrapping, or None. Never prompts.""" + if PROJECT_VAULT_PASSPHRASE: + return "argument" + if legacy_vault_passphrase_env_names(): + return "environment" + return None + + +def memory_secret_deprecations(): + names = legacy_vault_passphrase_env_names() + if not names: + return [] + return [{ + "code": "vault_passphrase_env_deprecated", + "variables": names, + "message": ( + "Passing the project vault passphrase to agents through " + + " / ".join(names) + + " is deprecated. Provision the memory secret once with " + f"`{MEMORY_SECRET_SETUP_COMMAND}`, then remove the variable from the agent environment." + ), + "next_action": MEMORY_SECRET_SETUP_COMMAND, + }] + + +def note_legacy_passphrase_use(project_id): + if memory_secret_passphrase_source() != "environment": + return + names = tuple(legacy_vault_passphrase_env_names()) + if names in MEMORY_SECRET_DEPRECATION_LOGGED: + return + MEMORY_SECRET_DEPRECATION_LOGGED.add(names) + gateway_log( + "vault_passphrase_env_deprecated", + level="warn", + project_id=project_id, + variables=list(names), + next_action=MEMORY_SECRET_SETUP_COMMAND, + ) + + +def memory_secret_vault_generation(): + try: + stat_result = os.stat(MEMORY_SECRET_VAULT_PATH) + except FileNotFoundError: + return None + except OSError: + return ("unreadable",) + return (stat_result.st_ino, stat_result.st_mtime_ns, stat_result.st_size) + + +def memory_secret_vault_mark_seen(): + with MEMORY_SECRET_VAULT_LOCK: + MEMORY_SECRET_VAULT_STATE["seen_generation"] = memory_secret_vault_generation() + MEMORY_SECRET_VAULT_STATE["seen_initialized"] = True + MEMORY_SECRET_VAULT_STATE["summary_key"] = None + MEMORY_SECRET_VAULT_STATE["summary"] = None + + +def memory_secret_cache_revalidate(): + """Drop cached memory secrets when the local vault changed under this process. + + A long-lived MCP worker therefore stops using a secret after + `nap gateway vault forget` (or re-provisioning) in another process. + """ + generation = memory_secret_vault_generation() + with MEMORY_SECRET_VAULT_LOCK: + state = MEMORY_SECRET_VAULT_STATE + if not state["seen_initialized"]: + state["seen_generation"] = generation + state["seen_initialized"] = True + return True + if state["seen_generation"] == generation: + return True + state["seen_generation"] = generation + MEMORY_SECRET_CACHE.clear() + ACTIVE_MEMORY_SECRET_VERSIONS.clear() + return False + + +def read_memory_secret_vault(): + """Decrypted local memory secret payload, None when absent; fails closed.""" + path = MEMORY_SECRET_VAULT_PATH + try: + if path.is_symlink(): + raise MemorySecretVaultUnreadable("symlink") + if not path.exists(): + return None + except OSError: + raise MemorySecretVaultUnreadable("stat_failed") from None + try: + local_key = read_local_vault_key(create=False) + except Exception: + local_key = None + if not local_key: + raise MemorySecretVaultUnreadable("local_key_missing") + try: + payload = read_vault(local_key, path=path, schema=MEMORY_SECRET_VAULT_SCHEMA) + except Exception: + raise MemorySecretVaultUnreadable("open_failed") from None + if payload.get("version") != 1 or not isinstance(payload.get("projects"), dict): + raise MemorySecretVaultUnreadable("format") + return payload + + +def memory_secret_vault_entries(payload, project_id): + projects = payload.get("projects") if isinstance(payload, dict) else None + record = projects.get(str(project_id)) if isinstance(projects, dict) else None + entries = record.get("entries") if isinstance(record, dict) else None + return [entry for entry in entries if isinstance(entry, dict)] if isinstance(entries, list) else [] + + +def memory_secret_from_vault_entry(project_id, entry): + try: + if entry.get("secret_kind") != MEMORY_SECRET_KIND: + return None + identity = tuple(int(entry[field]) for field in ("version", "wrapping_epoch", "bundle_version", "data_key_epoch")) + secret_bytes = base64.b64decode(str(entry.get("secret_b64") or ""), validate=True) + except Exception: + return None + if min(identity) <= 0 or len(secret_bytes) < 32: + return None + return { + "project_id": str(project_id), + "secret_kind": MEMORY_SECRET_KIND, + **memory_secret_identity_fields(identity), + "secret_bytes": secret_bytes, + "created_at": str(entry.get("created_at") or ""), + "last_rotated_at": str(entry.get("last_rotated_at") or ""), + } + + +def memory_secret_vault_entry(secret, source): + return { + "secret_kind": MEMORY_SECRET_KIND, + **memory_secret_identity_fields(memory_secret_identity(secret)), + "secret_b64": base64.b64encode(bytes(secret["secret_bytes"])).decode("ascii"), + "created_at": secret.get("created_at") or "", + "last_rotated_at": secret.get("last_rotated_at") or "", + "stored_at": iso_now(), + "source": source, + } + + +def memory_secret_local_key_marker(): + try: + local_key = read_local_vault_key(create=False) + except Exception: + return "unreadable" + return hashlib.sha256(local_key.encode("utf-8")).hexdigest() if local_key else None + + +def memory_secret_vault_summary(): + """Identities provisioned per project (no secret bytes), cached per file generation.""" + generation = memory_secret_vault_generation() + if generation is None: + return {"state": "absent", "projects": {}} + cache_key = (str(MEMORY_SECRET_VAULT_PATH), generation, memory_secret_local_key_marker()) + with MEMORY_SECRET_VAULT_LOCK: + if MEMORY_SECRET_VAULT_STATE["summary_key"] == cache_key: + return MEMORY_SECRET_VAULT_STATE["summary"] + try: + payload = read_memory_secret_vault() + except MemorySecretVaultUnreadable as exc: + summary = {"state": "unreadable", "reason": exc.reason, "projects": {}} + else: + projects = {} + for project_id in (payload or {}).get("projects", {}): + identities = [] + for entry in memory_secret_vault_entries(payload, project_id): + secret = memory_secret_from_vault_entry(project_id, entry) + if secret is not None: + identities.append(memory_secret_identity(secret)) + if identities: + projects[str(project_id)] = sorted(identities, reverse=True) + summary = {"state": "readable" if payload is not None else "absent", "projects": projects} + with MEMORY_SECRET_VAULT_LOCK: + MEMORY_SECRET_VAULT_STATE["summary_key"] = cache_key + MEMORY_SECRET_VAULT_STATE["summary"] = summary + return summary + + +def memory_secret_from_local_vault(project_id, *, identity=None, version=None): + """Stored secret for an exact identity (or any identity of a version); None fails closed. + + The cached summary answers misses without decrypting, so per-node lookups + of versions that were never provisioned stay cheap. + """ + summary = memory_secret_vault_summary() + stored = summary["projects"].get(str(project_id)) or [] + if identity is not None: + wanted = tuple(int(value) for value in identity) in stored + else: + wanted = any(item[0] == int(version or 0) for item in stored) + if summary["state"] == "unreadable": + gateway_periodic_log( + "memory_secret_vault_unreadable", + 300, + "memory_secret_vault_unreadable", + level="warn", + reason=summary.get("reason"), + next_action=MEMORY_SECRET_SETUP_COMMAND, + ) + if not wanted: + return None + try: + payload = read_memory_secret_vault() + except MemorySecretVaultUnreadable: + return None + if payload is None: + return None + for entry in memory_secret_vault_entries(payload, project_id): + secret = memory_secret_from_vault_entry(project_id, entry) + if secret is None: + continue + stored_identity = memory_secret_identity(secret) + if identity is not None and stored_identity == tuple(identity): + return secret + if identity is None and version is not None and stored_identity[0] == int(version): + return secret + return None + + +def memory_secret_vault_store(project_id, secrets_list, *, source, replace_unreadable=False): + """Atomically add/replace entries (one per version) for a project.""" + project_id = str(project_id) + new_entries = [memory_secret_vault_entry(secret, source) for secret in secrets_list] + if not new_entries: + return 0 + replaced_versions = {int(entry["version"]) for entry in new_entries} + with MEMORY_SECRET_VAULT_LOCK, credential_lock(MEMORY_SECRET_VAULT_PATH): + try: + payload = read_memory_secret_vault() + except MemorySecretVaultUnreadable: + if not replace_unreadable: + raise + payload = None + payload = payload or {"version": 1, "projects": {}} + kept = [ + entry for entry in memory_secret_vault_entries(payload, project_id) + if int(entry.get("version") or 0) not in replaced_versions + ] + entries = sorted(new_entries + kept, key=lambda entry: int(entry.get("version") or 0), reverse=True) + payload["projects"][project_id] = {"entries": entries[:MEMORY_SECRET_VAULT_MAX_ENTRIES], "updated_at": iso_now()} + payload["updated_at"] = iso_now() + write_vault( + read_local_vault_key(create=True), + payload, + path=MEMORY_SECRET_VAULT_PATH, + schema=MEMORY_SECRET_VAULT_SCHEMA, + ) + memory_secret_vault_mark_seen() + return len(new_entries) + + +def memory_secret_persist_best_effort(project_id, secrets_list, source="passphrase"): + try: + memory_secret_vault_store(project_id, secrets_list, source=source) + return True + except Exception as exc: + gateway_log( + "memory_secret_persist_failed", + level="warn", + project_id=project_id, + error=safe_exception_text(exc), + ) + return False + + +def memory_secret_vault_forget(project_id): + """Remove a project's stored entries; an unreadable vault is removed whole.""" + project_id = str(project_id) + with MEMORY_SECRET_VAULT_LOCK, credential_lock(MEMORY_SECRET_VAULT_PATH): + try: + payload = read_memory_secret_vault() + except MemorySecretVaultUnreadable as exc: + MEMORY_SECRET_VAULT_PATH.unlink(missing_ok=True) + memory_secret_vault_mark_seen() + return {"removed_entries": None, "removed_unreadable_vault": True, "reason": exc.reason} + if payload is None or project_id not in payload["projects"]: + return {"removed_entries": 0, "removed_unreadable_vault": False} + removed = len(memory_secret_vault_entries(payload, project_id)) + payload["projects"].pop(project_id, None) + if payload["projects"]: + payload["updated_at"] = iso_now() + write_vault( + read_local_vault_key(create=True), + payload, + path=MEMORY_SECRET_VAULT_PATH, + schema=MEMORY_SECRET_VAULT_SCHEMA, + ) + else: + MEMORY_SECRET_VAULT_PATH.unlink(missing_ok=True) + memory_secret_vault_mark_seen() + return {"removed_entries": removed, "removed_unreadable_vault": False} + + +def remember_memory_secret(project_id, secret, active=False): + slot = memory_secret_cache_slot(project_id, *memory_secret_identity(secret)) + MEMORY_SECRET_CACHE[slot] = secret + if active: + ACTIVE_MEMORY_SECRET_VERSIONS[memory_secret_cache_key(project_id)] = slot + return secret + + +def memory_secret_missing(project_id, identity): + stored = memory_secret_vault_summary()["projects"].get(str(project_id)) or [] + if stored: + MEMORY_SECRET_ROTATION_OBSERVED[str(project_id)] = tuple(identity) + return MemorySecretUnavailable(project_id, "rotated", active_identity=identity) + return MemorySecretUnavailable(project_id, "not_provisioned", active_identity=identity) + + def active_memory_secret(project_id, force_refresh=False): + """Resolve the active memory secret without ever prompting. + + Order: in-process cache -> local vault entry for the server's active + identity -> non-interactive passphrase (tool argument, or the deprecated + environment variables; persisted on success) -> not provisioned/rotated. + """ project_id = memory_secret_cache_key(project_id) if not project_id: raise RuntimeError("project_id is required") + memory_secret_cache_revalidate() active_slot = ACTIVE_MEMORY_SECRET_VERSIONS.get(project_id) cached = MEMORY_SECRET_CACHE.get(active_slot) if active_slot else None if cached and not force_refresh: @@ -4042,11 +4502,18 @@ def active_memory_secret(project_id, force_refresh=False): "GET", f"/v1/projects/{project_id}/vault/secrets/{MEMORY_SECRET_KIND}/active", ) - secret = normalize_memory_secret_response(project_id, response, "active memory secret is unavailable") - slot = memory_secret_cache_slot(project_id, secret["version"], secret["wrapping_epoch"], secret["bundle_version"], secret["data_key_epoch"]) - MEMORY_SECRET_CACHE[slot] = secret - ACTIVE_MEMORY_SECRET_VERSIONS[project_id] = slot - return secret + identity, _wrapped = memory_secret_response_identity(project_id, response, "active memory secret is unavailable") + secret = MEMORY_SECRET_CACHE.get(memory_secret_cache_slot(project_id, *identity)) + if secret is None: + secret = memory_secret_from_local_vault(project_id, identity=identity) + if secret is None: + if not memory_secret_passphrase_source(): + raise memory_secret_missing(project_id, identity) + secret = normalize_memory_secret_response(project_id, response, "active memory secret is unavailable") + note_legacy_passphrase_use(project_id) + memory_secret_persist_best_effort(project_id, [secret]) + MEMORY_SECRET_ROTATION_OBSERVED.pop(project_id, None) + return remember_memory_secret(project_id, secret, active=True) def versioned_memory_secret(project_id, version): @@ -4054,10 +4521,16 @@ def versioned_memory_secret(project_id, version): version = int(version or 0) if version <= 0: raise RuntimeError("encrypted memory content is unavailable: missing memory secret version") + memory_secret_cache_revalidate() cached_slot = find_memory_secret_cache_slot(project_id, version) cached = MEMORY_SECRET_CACHE.get(cached_slot) if cached_slot else None if cached: return cached + stored = memory_secret_from_local_vault(project_id, version=version) + if stored is not None: + return remember_memory_secret(project_id, stored) + if not memory_secret_passphrase_source(): + raise MemorySecretUnavailable(project_id, "not_provisioned", version=version) response = request_json( "GET", f"/v1/projects/{project_id}/vault/secrets/{MEMORY_SECRET_KIND}/versions/{version}", @@ -4065,9 +4538,9 @@ def versioned_memory_secret(project_id, version): secret = normalize_memory_secret_response(project_id, response, "versioned memory secret is unavailable") if int(secret["version"]) != version: raise RuntimeError("versioned memory secret is unavailable") - slot = memory_secret_cache_slot(project_id, version, secret["wrapping_epoch"], secret["bundle_version"], secret["data_key_epoch"]) - MEMORY_SECRET_CACHE[slot] = secret - return secret + note_legacy_passphrase_use(project_id) + memory_secret_persist_best_effort(project_id, [secret]) + return remember_memory_secret(project_id, secret) def memory_secret_for_read(project_id, version): @@ -4075,16 +4548,175 @@ def memory_secret_for_read(project_id, version): version = int(version or 0) if version <= 0: raise RuntimeError("encrypted memory content is unavailable: missing memory secret version") + memory_secret_cache_revalidate() cached_slot = find_memory_secret_cache_slot(project_id, version) cached = MEMORY_SECRET_CACHE.get(cached_slot) if cached_slot else None if cached: return cached - active = active_memory_secret(project_id) + try: + active = active_memory_secret(project_id) + except MemorySecretUnavailable as exc: + # Older node versions keep their stored secret; the active version + # itself must be re-provisioned after a rotation. + if exc.active_identity and version < exc.active_identity[0]: + stored = memory_secret_from_local_vault(project_id, version=version) + if stored is not None: + return remember_memory_secret(project_id, stored) + raise if int(active["version"]) == version: return active return versioned_memory_secret(project_id, version) +def memory_secret_status(project_id=None, *, check_server=False): + """memory_secret: provisioned | not_provisioned | rotated. No secret material.""" + project_id = memory_secret_cache_key(project_id) + summary = memory_secret_vault_summary() + identities = (summary["projects"].get(project_id) or []) if project_id else [] + result = { + "memory_secret": "provisioned" if identities else "not_provisioned", + "memory_secret_vault_path": str(MEMORY_SECRET_VAULT_PATH), + "memory_secret_versions": [identity[0] for identity in identities], + } + if summary["state"] == "unreadable": + result["memory_secret_detail"] = "local_vault_unreadable" + if identities and project_id in MEMORY_SECRET_ROTATION_OBSERVED: + result["memory_secret"] = "rotated" + if check_server and identities: + try: + response = request_json("GET", f"/v1/projects/{project_id}/vault/secrets/{MEMORY_SECRET_KIND}/active") + active_identity, _wrapped = memory_secret_response_identity(project_id, response, "active memory secret is unavailable") + except Exception as exc: + result["memory_secret_server_check"] = {"status": "unavailable", "error": safe_exception_payload(exc)} + else: + if tuple(active_identity) in {tuple(identity) for identity in identities}: + MEMORY_SECRET_ROTATION_OBSERVED.pop(project_id, None) + result["memory_secret"] = "provisioned" + else: + MEMORY_SECRET_ROTATION_OBSERVED[project_id] = tuple(active_identity) + result["memory_secret"] = "rotated" + result["memory_secret_server_check"] = {"status": "ok", "active_version": active_identity[0]} + if result["memory_secret"] != "provisioned": + result["memory_secret_next_action"] = MEMORY_SECRET_SETUP_COMMAND + deprecations = memory_secret_deprecations() + if deprecations: + result["deprecations"] = deprecations + return result + + +def memory_secret_setup_passphrase(args): + value = args.get("vault_passphrase") or args.get("passphrase") + if isinstance(value, str) and value.strip(): + return value.strip(), "argument" + names = legacy_vault_passphrase_env_names() + if names: + return os.environ[names[0]], "environment" + if interactive_prompt_allowed(): + value = getpass.getpass("Project vault passphrase (used once; not stored): ") + if value and value.strip(): + return value.strip(), "prompt" + raise SafeToolError( + "passphrase_required", + f"Run `{MEMORY_SECRET_SETUP_COMMAND}` in a terminal to enter the project vault passphrase once. " + "The passphrase is used to unwrap the project memory secret and is never stored.", + ) + + +def memory_secret_setup(args=None): + """Explicit provisioning: unwrap the memory secret once and store it locally. + + Stores the unwrapped secret (active version plus retrievable older + versions) in the local encrypted vault; never the passphrase. + """ + global PROJECT_VAULT_PASSPHRASE + args = args or {} + project_id = str(args.get("project_id") or current_project_id()).strip() + if not project_id: + raise SafeToolError("project_not_configured", "No project is configured for this directory; run `nap project attach` first.") + passphrase, passphrase_source = memory_secret_setup_passphrase(args) + previous = PROJECT_VAULT_PASSPHRASE + PROJECT_VAULT_PASSPHRASE = passphrase + try: + response = request_json("GET", f"/v1/projects/{project_id}/vault/secrets/{MEMORY_SECRET_KIND}/active") + try: + active = normalize_memory_secret_response(project_id, response, "active memory secret is unavailable") + except RuntimeError: + raise SafeToolError( + "memory_secret_unwrap_failed", + "The project memory secret could not be opened with that passphrase; nothing was stored.", + ) from None + older = [] + unavailable = [] + lowest = max(1, active["version"] - MEMORY_SECRET_OLDER_VERSION_LIMIT) + for version in range(active["version"] - 1, lowest - 1, -1): + try: + older.append(versioned_memory_secret_unwrapped(project_id, version)) + except Exception: + unavailable.append(version) + finally: + PROJECT_VAULT_PASSPHRASE = previous + passphrase = None + memory_secret_vault_store(project_id, [active, *older], source="setup", replace_unreadable=True) + stored = memory_secret_from_local_vault(project_id, identity=memory_secret_identity(active)) + if stored is None or not hmac.compare_digest(stored["secret_bytes"], active["secret_bytes"]): + raise SafeToolError("memory_secret_verify_failed", "The stored memory secret could not be verified; run setup again.") + MEMORY_SECRET_ROTATION_OBSERVED.pop(project_id, None) + remember_memory_secret(project_id, stored, active=True) + result = { + "status": "provisioned", + "project_id": project_id, + **memory_secret_status(project_id), + "active_identity": memory_secret_identity_fields(memory_secret_identity(active)), + "older_versions_stored": sorted(secret["version"] for secret in older), + "older_versions_unavailable": sorted(unavailable), + "passphrase_source": passphrase_source, + "passphrase_stored": False, + "local_vault_key_path": str(LOCAL_VAULT_KEY_PATH), + "message": ( + "Project memory secret stored in local encrypted storage for this worktree. " + "The CLI and MCP workers load it automatically; the passphrase was not stored. " + f"Remove it with `{MEMORY_SECRET_FORGET_COMMAND}`." + ), + } + return result + + +def versioned_memory_secret_unwrapped(project_id, version): + response = request_json( + "GET", + f"/v1/projects/{project_id}/vault/secrets/{MEMORY_SECRET_KIND}/versions/{int(version)}", + ) + secret = normalize_memory_secret_response(project_id, response, "versioned memory secret is unavailable") + if int(secret["version"]) != int(version): + raise RuntimeError("versioned memory secret is unavailable") + return secret + + +def memory_secret_forget(args=None): + args = args or {} + project_id = str(args.get("project_id") or current_project_id()).strip() + if not project_id: + raise SafeToolError("project_not_configured", "No project is configured for this directory.") + removed = memory_secret_vault_forget(project_id) + for slot in list(MEMORY_SECRET_CACHE): + if len(slot) >= 2 and slot[0] == project_id and slot[1] == MEMORY_SECRET_KIND: + MEMORY_SECRET_CACHE.pop(slot, None) + ACTIVE_MEMORY_SECRET_VERSIONS.pop(project_id, None) + MEMORY_SECRET_ROTATION_OBSERVED.pop(project_id, None) + # The encrypted E2E search shard is derived from the secret; drop it too. + e2e_shard_forget(project_id) + return { + "status": "removed" if removed.get("removed_entries") or removed.get("removed_unreadable_vault") else "not_provisioned", + "project_id": project_id, + **removed, + **memory_secret_status(project_id), + "message": ( + "Local memory secret access removed for this worktree. Running MCP workers stop using it on their next " + f"memory access. Provision again with `{MEMORY_SECRET_SETUP_COMMAND}`." + ), + } + + def project_encryption_state_strict(project_id): return request_json("GET", f"/v1/projects/{project_id}/encryption/status").get("state") or "standard" @@ -4375,6 +5007,7 @@ def gateway_vault_status(args=None): "project_id": project_id, "locked": not gateway_is_unlocked(), "vault_configured": vault_exists(), + **memory_secret_status_safe(project_id, check_server=True), "pending_setup_requests": len(pending), "items": items, "message": ( @@ -4451,9 +5084,24 @@ def gateway_process_vault_setup_requests(args=None): "completed_count": len(completed), "completed": completed, "skipped": skipped, + # The passphrase is in hand: store the new memory secret locally too. + "local_memory_secret": memory_secret_store_with_passphrase(project_id) if completed else None, } +def memory_secret_store_with_passphrase(project_id): + """Best effort after a passphrase-holding flow (setup completion, rotation).""" + project_id = str(project_id) + if not memory_secret_passphrase_source(): + return {"stored": False, **memory_secret_status_safe(project_id)} + ACTIVE_MEMORY_SECRET_VERSIONS.pop(project_id, None) + try: + active_memory_secret(project_id, force_refresh=True) + except Exception as exc: + return {"stored": False, "error": safe_exception_payload(exc), **memory_secret_status_safe(project_id)} + return {"stored": True, **memory_secret_status_safe(project_id)} + + def gateway_rotate_project_vault_secret(args=None): args = args or {} require_unlocked("rotating project vault secret") @@ -4515,6 +5163,9 @@ def gateway_rotate_project_vault_secret(args=None): for slot in list(MEMORY_SECRET_CACHE): if len(slot) >= 2 and slot[0] == project_id and slot[1] == MEMORY_SECRET_KIND: MEMORY_SECRET_CACHE.pop(slot, None) + if isinstance(result, dict): + # Re-provision the rotated version locally while the passphrase is in hand. + result = {**result, "local_memory_secret": memory_secret_store_with_passphrase(project_id)} gateway_log( "project_vault_secret_rotated", project_id=project_id, @@ -9047,10 +9698,10 @@ E2E_SHARDS_LOCK = threading.Lock() E2E_RECONCILE_STATE = {} E2E_WARNING_MESSAGES = { - "e2e_index_locked": ( - "End-to-end encrypted node bodies were not searched: the project memory secret is locked on this " - "machine. Provide the project vault passphrase to the MCP server (NAPSEER_VAULT_PASSPHRASE) or run a " - "command that unlocks it, then search again." + "e2e_index_not_provisioned": ( + "End-to-end encrypted node bodies were not searched: the project memory secret is not provisioned on " + "this machine, or was rotated after it was provisioned. Run `nap gateway vault setup` once in a terminal; " + "the CLI and MCP workers then load it automatically." ), "e2e_index_unavailable": ( "End-to-end encrypted node bodies were not searched: the project memory secret could not be loaded. " @@ -9079,8 +9730,8 @@ E2E_WARNING_MESSAGES = { } -class E2EShardLocked(Exception): - """The memory secret needed for E2E text is not available without a prompt.""" +class E2EShardNotProvisioned(Exception): + """The memory secret needed for E2E text is not provisioned locally (never prompts).""" class E2EShardSecretUnavailable(Exception): @@ -9108,15 +9759,23 @@ def e2e_shard_path(project_id): return AUTH_DIR / f"index-e2e-{digest}.enc" -def e2e_noninteractive_passphrase_available(): - return bool( - PROJECT_VAULT_PASSPHRASE - or os.environ.get("NAPSEER_VAULT_PASSPHRASE") - or os.environ.get("NAPSEER_MASTER_PASSPHRASE") - ) +def e2e_memory_secret_obtainable(project_id): + """True when resolution can succeed without a prompt: a provisioned local + entry for the project, or a non-interactive passphrase.""" + if memory_secret_passphrase_source(): + return True + return bool(memory_secret_vault_summary()["projects"].get(str(project_id))) + + +def e2e_not_provisioned_warning(project_id, state=None): + state = state or memory_secret_status(project_id)["memory_secret"] + if state == "provisioned": + state = "rotated" + return e2e_warning("e2e_index_not_provisioned", memory_secret=state, next_action=MEMORY_SECRET_SETUP_COMMAND) def e2e_cached_memory_secret(project_id, version=None): + memory_secret_cache_revalidate() if version is None: slot = ACTIVE_MEMORY_SECRET_VERSIONS.get(memory_secret_cache_key(project_id)) else: @@ -9129,10 +9788,12 @@ def e2e_memory_secret_for_read(project_id, version): cached = e2e_cached_memory_secret(project_id, int(version or 0)) if cached: return cached - if not e2e_noninteractive_passphrase_available(): - raise E2EShardLocked() + if not e2e_memory_secret_obtainable(project_id): + raise E2EShardNotProvisioned() try: return memory_secret_for_read(project_id, version) + except MemorySecretUnavailable as exc: + raise E2EShardNotProvisioned() from exc except Exception as exc: raise E2EShardSecretUnavailable(safe_exception_text(exc)) from exc @@ -9340,7 +10001,7 @@ def e2e_shard_entry(node, project_id): return entry try: decrypted = decrypt_node_for_return(node, project_id=project_id, secret_resolver=e2e_memory_secret_for_read) - except (E2EShardLocked, E2EShardSecretUnavailable): + except (E2EShardNotProvisioned, E2EShardSecretUnavailable): return None except Exception: entry["state"] = "undecryptable" @@ -9377,6 +10038,8 @@ class E2EShard: self.persist_skipped = False self.last_error = None self.secret_retry_at = 0.0 + self.secret_retry_generation = None + self.secret_problem = None self.secret_error = None # -- lifecycle ----------------------------------------------------- @@ -9499,16 +10162,33 @@ def e2e_shard_active_secret(shard, allow_fetch): cached = e2e_cached_memory_secret(shard.project_id) if cached: return cached, None - if not e2e_noninteractive_passphrase_available(): - return None, e2e_warning("e2e_index_locked") - if not allow_fetch or time.monotonic() < shard.secret_retry_at: + if not e2e_memory_secret_obtainable(shard.project_id): + return None, e2e_not_provisioned_warning(shard.project_id, "not_provisioned") + # A failure backs off, unless the local vault changed since (re-provisioned). + backing_off = ( + time.monotonic() < shard.secret_retry_at + and shard.secret_retry_generation == memory_secret_vault_generation() + ) + if not allow_fetch or backing_off: + if shard.secret_problem: + return None, e2e_not_provisioned_warning(shard.project_id, shard.secret_problem) return None, e2e_warning("e2e_index_unavailable", cause=shard.secret_error) try: - return active_memory_secret(shard.project_id), None + secret = active_memory_secret(shard.project_id) + except MemorySecretUnavailable as exc: + shard.secret_retry_at = time.monotonic() + E2E_SHARD_SECRET_RETRY_SECONDS + shard.secret_retry_generation = memory_secret_vault_generation() + shard.secret_problem = exc.memory_secret + shard.secret_error = exc.safe_message + return None, e2e_not_provisioned_warning(shard.project_id, exc.memory_secret) except Exception as exc: shard.secret_retry_at = time.monotonic() + E2E_SHARD_SECRET_RETRY_SECONDS + shard.secret_retry_generation = memory_secret_vault_generation() + shard.secret_problem = None shard.secret_error = safe_exception_text(exc) return None, e2e_warning("e2e_index_unavailable", cause=shard.secret_error) + shard.secret_problem = None + return secret, None def e2e_shard_read_file(shard, identity, key, key_check): @@ -9565,7 +10245,7 @@ def e2e_shard_open(project_id, *, allow_fetch=True, wait=False): if secret is None: with shard.lock: if shard.conn is not None: - # Locked since load: stop serving plaintext; keep the file. + # Secret gone since load (forgotten/rotated): stop serving plaintext; keep the file. e2e_shard_flush(shard, force=True) shard.close_memory() return None, [problem] @@ -9663,6 +10343,20 @@ def e2e_shards_close(*, flush=True): shard.close_memory() +def e2e_shard_forget(project_id): + """Drop a project's shard (memory and file) after its secret was removed.""" + with E2E_SHARDS_LOCK: + shard = E2E_SHARDS.pop((str(AUTH_DIR), str(project_id)), None) + if shard is not None: + with shard.lock: + shard.discard("memory_secret_removed", report=False) + return + try: + e2e_shard_path(project_id).unlink() + except FileNotFoundError: + pass + + def e2e_shards_remove_nodes(node_ids): """Apply removals to every loaded shard (unloaded shards reconcile on load).""" with E2E_SHARDS_LOCK: @@ -10036,7 +10730,7 @@ def e2e_shard_status(project_id): "last_error": shard.last_error, }) else: - status["locked"] = not (e2e_cached_memory_secret(project_id) or e2e_noninteractive_passphrase_available()) + status["memory_secret"] = memory_secret_status(project_id)["memory_secret"] return status @@ -20912,10 +21606,18 @@ def doctor(args): "contract": contract_profile({"include_tools": False}), "state_dir": state_dir_status_payload(), "crypto": crypto_backend_status(), + "memory_secret": memory_secret_status_safe(check_server=True), } profile_warnings = report["contract"].get("tool_profiles", {}).get("warnings") or [] - if profile_warnings: - report["warnings"] = list(profile_warnings) + warnings = list(profile_warnings) + warnings.extend(item["message"] for item in report["memory_secret"].get("deprecations") or []) + if report["memory_secret"].get("memory_secret") == "rotated": + warnings.append( + "The project memory secret was rotated after it was provisioned here; run " + f"`{MEMORY_SECRET_SETUP_COMMAND}` to store the current version." + ) + if warnings: + report["warnings"] = warnings return report @@ -21798,7 +22500,9 @@ Refresh is automatic during authenticated calls; use `repair` only for recovery. action = args[0] if args else "status" vault_args = args[1:] if args else [] if action in {"help", "-h", "--help"}: - print("Usage: napseer_mcp_server.py gateway vault [status|process|rotate-secret] [--kind memory] [--all] [--project-id ID]") + print("Usage: napseer_mcp_server.py gateway vault [status|setup|forget|process|rotate-secret] [--kind memory] [--all] [--project-id ID]") + print(" setup Store the project memory secret in local encrypted storage (prompts once; the passphrase is not stored).") + print(" forget Remove the locally stored memory secret for this worktree.") print("Chat secrets use: nap chat secret setup|status|rotate") return payload = { @@ -21810,6 +22514,12 @@ Refresh is automatic during authenticated calls; use `repair` only for recovery. if action == "status": print(json.dumps(gateway_vault_status(payload), indent=2)) return + if action == "setup": + print(json.dumps(memory_secret_setup(payload), indent=2)) + return + if action == "forget": + print(json.dumps(memory_secret_forget(payload), indent=2)) + return if action == "process": print(json.dumps(gateway_process_vault_setup_requests(payload), indent=2)) return @@ -21821,7 +22531,12 @@ Refresh is automatic during authenticated calls; use `repair` only for recovery. raise RuntimeError( "unknown gateway vault action: list. Use `nap gateway vault status`." ) - raise RuntimeError("unknown gateway vault action; use status|process|rotate-secret") + if action in {"unlock", "lock", "provision", "remove", "rm", "reset"}: + raise RuntimeError( + f"unknown gateway vault action: {action}. Use `nap gateway vault setup` to store the memory " + "secret locally or `nap gateway vault forget` to remove it." + ) + raise RuntimeError("unknown gateway vault action; use status|setup|forget|process|rotate-secret") if subcommand == "setup": print(json.dumps(gateway_setup({ "passphrase": cli_option(args, "--passphrase", default=None), @@ -21842,7 +22557,7 @@ Refresh is automatic during authenticated calls; use `repair` only for recovery. f"unknown gateway subcommand: {subcommand}. " "Use one of: status, configure, vault, terminal, schedule, service, restart, kill, setup, rotate-passphrase." ) - print("Usage: napseer_mcp_server.py gateway [status|configure [--command CMD] [--target session:window.pane]|vault [status|process|rotate-secret] [--kind memory] [--all]|terminal [list|create|close|capture|key|input]|schedule [list|create|update|delete|run]|service [preregister|activate]|restart|kill|setup|rotate-passphrase --new-passphrase TEXT]") + print("Usage: napseer_mcp_server.py gateway [status|configure [--command CMD] [--target session:window.pane]|vault [status|setup|forget|process|rotate-secret] [--kind memory] [--all]|terminal [list|create|close|capture|key|input]|schedule [list|create|update|delete|run]|service [preregister|activate]|restart|kill|setup|rotate-passphrase --new-passphrase TEXT]") return if command == "chat": subcommand = argv[2] if len(argv) > 2 else "secret" @@ -21967,6 +22682,8 @@ Refresh is automatic during authenticated calls; use `repair` only for recovery. def main(): + global MCP_SERVING + MCP_SERVING = True # stdin carries JSON-RPC: never prompt, even on a TTY runtime = McpRequestRuntime() try: for line in sys.stdin: diff --git a/tests/test_cli_dispatch_consolidation.py b/tests/test_cli_dispatch_consolidation.py index dd712a9..c573322 100755 --- a/tests/test_cli_dispatch_consolidation.py +++ b/tests/test_cli_dispatch_consolidation.py @@ -188,6 +188,13 @@ def test_gateway_vault_aliases_rejected(): expect_unknown(mod, args, "gateway vault action: list") +def test_gateway_vault_unlock_verbs_point_to_setup_and_forget(): + """The memory secret is provisioned once, not unlocked per session.""" + mod = load_module() + for action in ("unlock", "lock", "provision", "remove", "reset"): + expect_unknown(mod, ["napseer_mcp_server.py", "gateway", "vault", action], "nap gateway vault setup") + + def test_gateway_rotate_passphrase_aliases_rejected(): mod = load_module() for args in [ diff --git a/tests/test_mcp_e2e_index_shard.py b/tests/test_mcp_e2e_index_shard.py index d6ed884..d7279b6 100644 --- a/tests/test_mcp_e2e_index_shard.py +++ b/tests/test_mcp_e2e_index_shard.py @@ -229,20 +229,23 @@ def test_e2e_body_term_is_findable_with_snippet_while_unlocked(env): assert not discovered.get("warnings") -def test_locked_secret_yields_no_e2e_hits_and_an_explicit_warning(env): +def test_unprovisioned_secret_yields_no_e2e_hits_and_an_explicit_warning(env): mod, provider, _ = env seed(mod, provider) lock(mod) local = local_search(mod, BODY_TERM) assert hit_ids(local) == [] - assert codes(local) == ["e2e_index_locked"] + assert codes(local) == ["e2e_index_not_provisioned"] + warning = local["warnings"][0] + assert warning["memory_secret"] == "not_provisioned" + assert warning["next_action"] == "nap gateway vault setup" response = mod.search_memory({"q": BODY_TERM, "mode": "local", "view": "summary"}) assert response["items"] == [] - assert "e2e_index_locked" in codes(response) + assert "e2e_index_not_provisioned" in codes(response) # Identity fields of the E2E node remain searchable from the main index. assert hit_ids(local_search(mod, "secretnote")) == ["secretnote"] - # A locked reindex never prompts and writes no shard. + # An unprovisioned reindex never prompts and writes no shard. shard_file(mod).unlink() assert "e2e_index" in mod.reindex_project({}) assert not shard_file(mod).exists() diff --git a/tests/test_mcp_memory_node_encryption.py b/tests/test_mcp_memory_node_encryption.py index 04c6ff2..e76a2a3 100644 --- a/tests/test_mcp_memory_node_encryption.py +++ b/tests/test_mcp_memory_node_encryption.py @@ -116,6 +116,8 @@ def run(): mod = load_module() test_state = tempfile.TemporaryDirectory() mod.AUTH_PATH = pathlib.Path(test_state.name) / "auth.json" + mod.MEMORY_SECRET_VAULT_PATH = pathlib.Path(test_state.name) / "memory-secrets.vault.json" + mod.LOCAL_VAULT_KEY_PATH = pathlib.Path(test_state.name) / "vault.local-key" mod.AUTH = {"account_id": ACCOUNT_ID, "token": "token", "token_expires_at": "later"} mod.PROJECT_VAULT_PASSPHRASE = PASSPHRASE mod.DEFAULT_PROJECT_ID = "project-1" @@ -485,9 +487,11 @@ def run(): mod.MEMORY_SECRET_CACHE.clear() mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear() mod.PROJECT_VAULT_PASSPHRASE = None + # Earlier passphrase unwraps persisted the secret locally; remove it. + mod.memory_secret_vault_forget("project-1") locked = mod.search_local_index({"q": "needle", "limit": 5}) assert locked["items"] == [] - assert [warning["code"] for warning in locked["warnings"]] == ["e2e_index_locked"] + assert [warning["code"] for warning in locked["warnings"]] == ["e2e_index_not_provisioned"] print("ok: local MCP memory node encryption smoke passed") diff --git a/tests/test_memory_secret_local_vault.py b/tests/test_memory_secret_local_vault.py new file mode 100644 index 0000000..af36926 --- /dev/null +++ b/tests/test_memory_secret_local_vault.py @@ -0,0 +1,674 @@ +"""Project memory secret provisioned once into the local vault, loaded automatically. + +Runs the real wrapper (in-process and as fresh CLI/MCP processes) against a +disposable HTTP provider that serves a memory secret wrapped with a fixture +passphrase. The fixture secret and passphrase are random per test and every +tool output, error, log line and state file is scanned for them (raw, base64, +hex) without ever printing them. +""" + +import base64 +import importlib.util +import json +import os +import pathlib +import pty +import queue +import re +import select +import stat +import subprocess +import sys +import termios +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit + +import pytest + +SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py" +PROJECT = "project-vault-1" +ACCOUNT = "22222222-2222-2222-2222-222222222222" +BODY_TERM = "zephyrquartz" +BODY = f"calibrate the {BODY_TERM} manifold before launch" +SETUP_COMMAND = "nap gateway vault setup" + + +# -- fixture material --------------------------------------------------------- + +class Material: + def __init__(self): + self.passphrase = "fixture-" + os.urandom(12).hex() + self.secrets = {} + + def secret(self, version): + return self.secrets.setdefault(version, os.urandom(32)) + + def forms(self): + forms = [self.passphrase.encode("utf-8")] + for value in self.secrets.values(): + encoded = base64.b64encode(value) + forms += [value, encoded, encoded.rstrip(b"="), base64.urlsafe_b64encode(value).rstrip(b"="), + value.hex().encode("ascii")] + return forms + + +def assert_no_leak(material, label, blob): + data = blob if isinstance(blob, bytes) else json.dumps(blob, default=str).encode("utf-8") \ + if not isinstance(blob, str) else blob.encode("utf-8") + for form in material.forms(): + if form in data: + pytest.fail(f"fixture secret material leaked into {label}") # never print the value + + +def assert_state_dir_clean(material, *roots): + for root in roots: + for path in pathlib.Path(root).rglob("*"): + if path.is_file(): + assert_no_leak(material, f"state file {path.name}", path.read_bytes()) + + +# -- disposable provider ------------------------------------------------------ + +def secret_record(material, mod, version, wrapping_epoch=1): + previous = mod.PROJECT_VAULT_PASSPHRASE + mod.PROJECT_VAULT_PASSPHRASE = material.passphrase + try: + bundle = mod.generate_project_data_key_bundle(PROJECT, ACCOUNT, data_key_epoch=version) + bundle["keys"]["memory"]["versions"][str(version)]["key_b64"] = base64.b64encode( + material.secret(version)).decode("ascii") + wrapped = mod.wrapped_project_key_bundle_record( + PROJECT, ACCOUNT, "memory", bundle, + wrapping_epoch=wrapping_epoch, bundle_version=version, data_key_epoch=version) + finally: + mod.PROJECT_VAULT_PASSPHRASE = previous + return { + "secret_kind": "memory", "version": version, "project_secret_version": version, + "wrapping_epoch": wrapping_epoch, "bundle_version": version, "data_key_epoch": version, + "wrapped_key_bundle": wrapped, "created_at": "2026-09-01T00:00:00Z", "last_rotated_at": "2026-09-01T00:00:00Z", + } + + +def e2e_node(mod, material, name, version, content, minute=1): + secret = { + "project_id": PROJECT, "secret_kind": "memory", "version": version, "wrapping_epoch": 1, + "bundle_version": version, "data_key_epoch": version, "secret_bytes": material.secret(version), + } + stamp = f"2026-09-01T12:{minute:02d}:00Z" + return { + "id": name, "project_id": PROJECT, "full_path": f"/notes/{name}", "folder_path": "/notes", + "name": name, "type": "note", "metadata": {}, "tags": [], "aliases": [], "links": [], + "content_text": "", "updated_at": stamp, "archived_at": None, "encryption_state": "encrypted", + "project_secret_version": version, + "encrypted_content_envelope": mod.encrypt_memory_bytes( + PROJECT, secret, "node_content", content.encode("utf-8"), {"full_path": f"/notes/{name}"}, + "text/plain; charset=utf-8"), + "encrypted_metadata_envelope": None, + } + + +class Provider: + def __init__(self): + self.nodes = {} + self.events = [] + self.requests = [] + self.active = None + self.versions = {} + self.vault_down = False + + def publish(self, record): + self.active = record + self.versions[record["version"]] = record + + def put(self, node): + self.nodes[node["id"]] = node + self.events.append({"type": "node_created", "node_id": node["id"], "full_path": node["full_path"], + "new_revision": node["updated_at"]}) + + +def start_provider(provider): + vault_re = re.compile(rf"/v1/projects/{PROJECT}/vault/secrets/memory/(active|versions/(\d+))") + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def reply(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + provider.requests.append(("POST", urlsplit(self.path).path)) + self.reply(404, {"error": {"code": "not_found", "message": "not served"}}) + + def do_GET(self): + url = urlsplit(self.path) + query = parse_qs(url.query) + provider.requests.append(("GET", url.path)) + match = vault_re.fullmatch(url.path) + if match: + if provider.vault_down: + return self.reply(503, {"error": {"code": "unavailable", "message": "vault down"}}) + record = provider.active if match.group(1) == "active" else provider.versions.get(int(match.group(2))) + if record is None: + return self.reply(404, {"error": {"code": "not_found", "message": "no such secret"}}) + return self.reply(200, record) + if url.path.endswith("/encryption/status"): + return self.reply(200, {"state": "encrypted"}) + if url.path.endswith("/changes"): + after = query.get("after", ["head"])[0] + start = len(provider.events) if after == "head" else int(after[1:]) + page = provider.events[start:] + items = [{**event, "cursor": f"c{start + i + 1}", "project_id": PROJECT, "changed_fields": [], + "created_at": "2026-09-01T12:00:00Z"} for i, event in enumerate(page)] + return self.reply(200, {"items": items, "next_cursor": f"c{start + len(page)}", "has_more": False}) + if url.path == f"/v1/projects/{PROJECT}/nodes": + rows = sorted(provider.nodes.values(), key=lambda n: (n["updated_at"], n["id"]), reverse=True) + return self.reply(200, {"items": rows, "next_cursor": None}) + node_id = url.path.rsplit("/", 1)[-1] + if "/nodes/" in url.path and node_id in provider.nodes: + return self.reply(200, provider.nodes[node_id]) + return self.reply(404, {"error": {"code": "not_found", "message": "not served"}}) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread + + +def load_module(name, state_dir, base_url): + spec = importlib.util.spec_from_file_location(name, SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + mod.AUTH_DIR = state_dir + mod.INDEX_PATH = state_dir / "index.sqlite" + mod.INDEX_LOCK_PATH = state_dir / "index.lock" + mod.VAULT_PATH = state_dir / "vault.json" + mod.LOCAL_VAULT_KEY_PATH = state_dir / "vault.local-key" + mod.MEMORY_SECRET_VAULT_PATH = state_dir / "memory-secrets.vault.json" + mod.BASE_URL = base_url + mod.TOKEN = "disposable-test-token" + mod.AUTH = {**mod.AUTH, "account_id": ACCOUNT, "project_id": PROJECT} + mod.DEFAULT_PROJECT_ID = PROJECT + mod.CONFIGURED_TOOL_PROFILES = {"all"} + mod.refresh_public_auth_state = lambda: None + mod.resolve_project_id = lambda args: args.get("project_id", PROJECT) + mod.project_encryption_state = lambda _project_id: "encrypted" + return mod + + +@pytest.fixture +def env(tmp_path, monkeypatch): + for name in ("NAPSEER_VAULT_PASSPHRASE", "NAPSEER_MASTER_PASSPHRASE", "NAPSEER_LOCAL_VAULT_KEY"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "all") + monkeypatch.setenv("NAPSEER_TELEMETRY", "0") + sqlite_tmp = tmp_path / "sqlite-tmp" + sqlite_tmp.mkdir() + monkeypatch.setenv("SQLITE_TMPDIR", str(sqlite_tmp)) + monkeypatch.syspath_prepend(str(SCRIPT.parent)) + provider = Provider() + server, thread = start_provider(provider) + base_url = f"http://127.0.0.1:{server.server_port}" + state_dir = tmp_path / "state" + mod = load_module("memory_vault_worker_a", state_dir, base_url) + material = Material() + provider.publish(secret_record(material, mod, 1)) + provider.put(e2e_node(mod, material, "secretnote", 1, BODY)) + try: + yield mod, provider, material, state_dir, base_url + finally: + mod.e2e_shards_close(flush=False) + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def fresh_process_state(mod): + """What a new worker starts with: no cached secret, no passphrase.""" + mod.e2e_shards_close(flush=True) + mod.MEMORY_SECRET_CACHE.clear() + mod.ACTIVE_MEMORY_SECRET_VERSIONS.clear() + mod.MEMORY_SECRET_ROTATION_OBSERVED.clear() + mod.PROJECT_VAULT_PASSPHRASE = None + + +def error_of(callable_, *args): + with pytest.raises(Exception) as info: + callable_(*args) + return info.value + + +# -- in-process behavior ------------------------------------------------------ + +def test_setup_stores_secret_not_passphrase_and_loads_without_it(env, capsys): + mod, provider, material, state_dir, _ = env + before = error_of(mod.active_memory_secret, PROJECT) + assert getattr(before, "code", None) == "memory_secret_not_provisioned" + assert mod.memory_secret_status(PROJECT)["memory_secret"] == "not_provisioned" + + result = mod.memory_secret_setup({"vault_passphrase": material.passphrase}) + assert result["status"] == "provisioned" and result["memory_secret"] == "provisioned" + assert result["passphrase_stored"] is False and result["passphrase_source"] == "argument" + assert result["active_identity"] == {"version": 1, "wrapping_epoch": 1, "bundle_version": 1, "data_key_epoch": 1} + assert mod.PROJECT_VAULT_PASSPHRASE is None # not retained after setup + + vault = mod.MEMORY_SECRET_VAULT_PATH + assert stat.S_IMODE(vault.stat().st_mode) == 0o600 + assert stat.S_IMODE(mod.LOCAL_VAULT_KEY_PATH.stat().st_mode) == 0o600 + assert json.loads(vault.read_text())["schema"] == "napseer.memory-secret-vault.v1" + + fresh_process_state(mod) + requests_before = len(provider.requests) + secret = mod.active_memory_secret(PROJECT) + assert secret["secret_bytes"] == material.secret(1) + # Identity comes from the server; no unwrap or passphrase was involved. + assert provider.requests[requests_before:] == [("GET", f"/v1/projects/{PROJECT}/vault/secrets/memory/active")] + whoami = mod.gateway_status() + assert whoami["memory_secret"] == "provisioned" and "deprecations" not in whoami + assert whoami["memory_secret_versions"] == [1] + + captured = capsys.readouterr() + for label, blob in (("setup result", result), ("whoami", whoami), ("stdout", captured.out), + ("stderr", captured.err)): + assert_no_leak(material, label, blob) + assert_state_dir_clean(material, state_dir) + + +def test_e2e_search_uses_provisioned_secret_and_reports_not_provisioned(env): + mod, provider, material, state_dir, _ = env + mod.reindex_project({}) + unprovisioned = mod.search_local_index({"q": BODY_TERM, "limit": 5}) + assert unprovisioned["items"] == [] + warning = unprovisioned["warnings"][0] + assert warning["code"] == "e2e_index_not_provisioned" + assert warning["memory_secret"] == "not_provisioned" and warning["next_action"] == SETUP_COMMAND + + mod.memory_secret_setup({"vault_passphrase": material.passphrase}) + fresh_process_state(mod) + mod.sync_local_index({}) + found = mod.search_local_index({"q": BODY_TERM, "limit": 5}) + assert [item["id"] for item in found["items"]] == ["secretnote"] + assert found["items"][0]["search_source"] == "local_e2e" and not found.get("warnings") + mod.e2e_shards_close(flush=True) + assert_state_dir_clean(material, state_dir) + + +def test_rotation_is_reported_older_versions_still_read_and_passphrase_reprovisions(env): + mod, provider, material, state_dir, _ = env + mod.memory_secret_setup({"vault_passphrase": material.passphrase}) + provider.publish(secret_record(material, mod, 2)) + provider.put(e2e_node(mod, material, "newnote", 2, "second body", minute=2)) + + fresh_process_state(mod) + rotated = error_of(mod.active_memory_secret, PROJECT) + assert rotated.code == "memory_secret_rotated" + assert rotated.details == {"memory_secret": "rotated", "next_action": SETUP_COMMAND, "active_version": 2} + assert mod.gateway_status()["memory_secret"] == "rotated" + assert mod.gateway_status()["memory_secret_next_action"] == SETUP_COMMAND + # The stored entry still serves nodes written under the older version... + assert mod.memory_secret_for_read(PROJECT, 1)["secret_bytes"] == material.secret(1) + assert mod.decrypt_node_for_return(provider.nodes["secretnote"], project_id=PROJECT)["content_text"] == BODY + # ...but never the rotated active version. + assert error_of(mod.memory_secret_for_read, PROJECT, 2).code == "memory_secret_rotated" + mod.reindex_project({}) + search = mod.search_local_index({"q": BODY_TERM, "limit": 5}) + assert search["items"] == [] + assert [(w["code"], w.get("memory_secret")) for w in search["warnings"]] == [("e2e_index_not_provisioned", "rotated")] + + # A doctor in a fresh process detects the rotation from the server identity. + fresh_process_state(mod) + mod.local_anchor_integrity = lambda: {} + doctor = mod.doctor({}) + assert doctor["memory_secret"]["memory_secret"] == "rotated" + assert any(SETUP_COMMAND in warning for warning in doctor["warnings"]) + + # A passphrase supplied through the tool-argument path re-provisions automatically. + mod.set_project_vault_passphrase(material.passphrase) + assert mod.active_memory_secret(PROJECT)["version"] == 2 + fresh_process_state(mod) + assert mod.active_memory_secret(PROJECT)["secret_bytes"] == material.secret(2) + assert mod.gateway_status()["memory_secret"] == "provisioned" + assert mod.gateway_status()["memory_secret_versions"] == [2, 1] + for blob in (rotated.details, str(rotated), doctor): + assert_no_leak(material, "rotation output", blob) + mod.e2e_shards_close(flush=True) + assert_state_dir_clean(material, state_dir) + + +def test_setup_after_rotation_stores_active_and_older_versions(env): + mod, provider, material, _state_dir, _ = env + provider.publish(secret_record(material, mod, 2)) + provider.publish(secret_record(material, mod, 3)) + del provider.versions[1] # an unavailable older version is reported, not fatal + result = mod.memory_secret_setup({"vault_passphrase": material.passphrase}) + assert result["active_identity"]["version"] == 3 + assert result["older_versions_stored"] == [2] and result["older_versions_unavailable"] == [1] + fresh_process_state(mod) + assert mod.memory_secret_for_read(PROJECT, 2)["secret_bytes"] == material.secret(2) + + +def test_forget_revokes_access_in_this_and_other_running_processes(env): + mod, provider, material, state_dir, base_url = env + mod.memory_secret_setup({"vault_passphrase": material.passphrase}) + mod.reindex_project({}) + assert [item["id"] for item in mod.search_local_index({"q": BODY_TERM})["items"]] == ["secretnote"] + mod.e2e_shards_close(flush=True) + assert mod.e2e_shard_path(PROJECT).exists() + + # Another running worker has the secret cached in memory. + other = load_module("memory_vault_worker_b", state_dir, base_url) + other.MEMORY_SECRET_ROTATION_OBSERVED.clear() + assert other.active_memory_secret(PROJECT)["secret_bytes"] == material.secret(1) + + removed = mod.memory_secret_forget({}) + assert removed["status"] == "removed" and removed["removed_entries"] == 1 + assert removed["memory_secret"] == "not_provisioned" + assert not mod.MEMORY_SECRET_VAULT_PATH.exists() + assert not mod.e2e_shard_path(PROJECT).exists() + + assert error_of(mod.active_memory_secret, PROJECT).code == "memory_secret_not_provisioned" + # The other worker drops its cached copy on the next memory access. + assert error_of(other.active_memory_secret, PROJECT).code == "memory_secret_not_provisioned" + assert other.e2e_cached_memory_secret(PROJECT) is None + search = mod.search_local_index({"q": BODY_TERM}) + assert search["items"] == [] and search["warnings"][0]["code"] == "e2e_index_not_provisioned" + assert mod.memory_secret_forget({})["status"] == "not_provisioned" + assert_no_leak(material, "forget output", removed) + + +@pytest.mark.parametrize("damage", ["tampered", "wrong_local_key", "missing_local_key", "garbage"]) +def test_damaged_vault_fails_closed_without_leaking(env, capsys, damage): + mod, provider, material, state_dir, _ = env + mod.memory_secret_setup({"vault_passphrase": material.passphrase}) + vault = mod.MEMORY_SECRET_VAULT_PATH + if damage == "tampered": + envelope = json.loads(vault.read_text()) + ciphertext = bytearray(base64.b64decode(envelope["cipher"]["ciphertext"])) + ciphertext[5] ^= 0x01 + envelope["cipher"]["ciphertext"] = base64.b64encode(bytes(ciphertext)).decode() + vault.write_text(json.dumps(envelope)) + elif damage == "wrong_local_key": + mod.LOCAL_VAULT_KEY_PATH.write_text(base64.b64encode(os.urandom(32)).decode() + "\n") + elif damage == "missing_local_key": + mod.LOCAL_VAULT_KEY_PATH.unlink() + else: + vault.write_bytes(b"{not json") + + fresh_process_state(mod) + failure = error_of(mod.active_memory_secret, PROJECT) + assert failure.code == "memory_secret_not_provisioned" + status = mod.gateway_status() + assert status["memory_secret"] == "not_provisioned" + assert status["memory_secret_detail"] == "local_vault_unreadable" + mod.reindex_project({}) + search = mod.search_local_index({"q": BODY_TERM}) + assert search["items"] == [] and search["warnings"][0]["code"] == "e2e_index_not_provisioned" + payload = mod.safe_exception_payload(failure) + captured = capsys.readouterr() + for label, blob in (("error", payload), ("status", status), ("search", search), + ("stdout", captured.out), ("stderr", captured.err)): + assert_no_leak(material, label, blob) + + # An explicit setup replaces the unreadable vault. + mod.memory_secret_setup({"vault_passphrase": material.passphrase}) + fresh_process_state(mod) + assert mod.active_memory_secret(PROJECT)["secret_bytes"] == material.secret(1) + + +def test_wrong_passphrase_stores_nothing(env): + mod, _provider, material, state_dir, _ = env + failure = error_of(mod.memory_secret_setup, {"vault_passphrase": "not-" + material.passphrase}) + assert failure.code == "memory_secret_unwrap_failed" + assert not mod.MEMORY_SECRET_VAULT_PATH.exists() + assert_no_leak(material, "unwrap failure", mod.safe_exception_payload(failure)) + + +def test_env_passphrase_still_works_reports_deprecation_and_is_persisted(env, monkeypatch, capsys): + mod, provider, material, state_dir, _ = env + monkeypatch.setenv("NAPSEER_VAULT_PASSPHRASE", material.passphrase) + assert mod.active_memory_secret(PROJECT)["secret_bytes"] == material.secret(1) + status = mod.gateway_status() + assert status["memory_secret"] == "provisioned" + assert [item["code"] for item in status["deprecations"]] == ["vault_passphrase_env_deprecated"] + assert status["deprecations"][0]["variables"] == ["NAPSEER_VAULT_PASSPHRASE"] + assert status["deprecations"][0]["next_action"] == SETUP_COMMAND + mod.local_anchor_integrity = lambda: {} + doctor = mod.doctor({}) + assert any("deprecated" in warning for warning in doctor["warnings"]) + log = capsys.readouterr().err + assert '"event":"vault_passphrase_env_deprecated"' in log + + # Later processes no longer need the variable. + monkeypatch.delenv("NAPSEER_VAULT_PASSPHRASE") + fresh_process_state(mod) + assert mod.active_memory_secret(PROJECT)["secret_bytes"] == material.secret(1) + assert "deprecations" not in mod.gateway_status() + for label, blob in (("status", status), ("doctor", doctor), ("log", log)): + assert_no_leak(material, label, blob) + assert_state_dir_clean(material, state_dir) + + +def test_mcp_mode_never_prompts_even_on_a_tty(env, monkeypatch): + mod, _provider, _material, _state_dir, _ = env + + class Tty: + def isatty(self): + return True + + def forbidden_prompt(*_args, **_kwargs): + raise AssertionError("MCP must never prompt") + + monkeypatch.setattr(mod.sys, "stdin", Tty()) + monkeypatch.setattr(mod.getpass, "getpass", forbidden_prompt) + mod.MCP_SERVING = True + assert error_of(mod.active_memory_secret, PROJECT).code == "memory_secret_not_provisioned" + assert "NAPSEER_VAULT_PASSPHRASE" in str(error_of(mod.project_bundle_passphrase)) + assert error_of(mod.memory_secret_setup, {}).code == "passphrase_required" + assert error_of(mod.decrypt_node_for_return, _provider.nodes["secretnote"], PROJECT).code == \ + "memory_secret_not_provisioned" + + +# -- fresh processes ---------------------------------------------------------- + +def child_env(user_data, base_url, **extra): + env = {key: value for key, value in os.environ.items() + if not key.startswith("NAPSEER_") or key == "NAPSEER_CRYPTO_BACKEND"} + env.update({ + "NAPSEER_USER_DATA_DIR": str(user_data), + "NAPSEER_BASE_URL": base_url, + "NAPSEER_TOKEN": "disposable-test-token", + "NAPSEER_PROJECT_ID": PROJECT, + "NAPSEER_ACCOUNT_ID": ACCOUNT, + "NAPSEER_TELEMETRY": "0", + "NAPSEER_TOOL_PROFILES": "all", + "PYTHONDONTWRITEBYTECODE": "1", + }) + env.update(extra) + return env + + +def no_echo_pty(): + master, slave = pty.openpty() + attrs = termios.tcgetattr(slave) + attrs[3] &= ~termios.ECHO + termios.tcsetattr(slave, termios.TCSANOW, attrs) + return master, slave + + +def run_interactive_setup(env, cwd, passphrase): + """`gateway vault setup` on a terminal: the passphrase is typed at the prompt.""" + master, slave = no_echo_pty() + process = subprocess.Popen( + [sys.executable, str(SCRIPT), "gateway", "vault", "setup"], + stdin=slave, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env, start_new_session=True, + ) + os.close(slave) + stderr = b"" + deadline = time.monotonic() + 30 + try: + while b"passphrase" not in stderr.lower(): + remaining = deadline - time.monotonic() + ready, _, _ = select.select([process.stderr], [], [], max(0.0, remaining)) + if not ready: + raise AssertionError("setup did not prompt for the passphrase") + chunk = os.read(process.stderr.fileno(), 4096) + if not chunk: + break + stderr += chunk + os.write(master, passphrase.encode("utf-8") + b"\n") + stdout, rest = process.communicate(timeout=60) + finally: + if process.poll() is None: + process.kill() + process.wait() + os.close(master) + return process.returncode, stdout, stderr + rest + + +class McpProcess: + def __init__(self, env, cwd, tty_stdin=False): + self.master = None + if tty_stdin: + self.master, slave = no_echo_pty() + stdin = slave + else: + stdin = subprocess.PIPE + self.process = subprocess.Popen( + [sys.executable, "-u", str(SCRIPT)], stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=cwd, env=env, start_new_session=True, + ) + if tty_stdin: + os.close(slave) + self.lines = queue.Queue() + self.stdout = [] + self.stderr = b"" + threading.Thread(target=self._read_stdout, daemon=True).start() + + def _read_stdout(self): + for line in self.process.stdout: + self.stdout.append(line) + self.lines.put(line) + + def send(self, message): + data = (json.dumps(message) + "\n").encode("utf-8") + if self.master is not None: + os.write(self.master, data) + else: + self.process.stdin.write(data) + self.process.stdin.flush() + + def request(self, request_id, method, params=None, timeout=60): + self.send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}}) + deadline = time.monotonic() + timeout + while True: + try: + message = json.loads(self.lines.get(timeout=max(0.01, deadline - time.monotonic()))) + except queue.Empty: + raise AssertionError(f"no MCP response to {method}") from None + if message.get("id") == request_id: + return message + + def call(self, request_id, name, arguments=None): + return self.request(request_id, "tools/call", {"name": name, "arguments": arguments or {}})["result"] + + def start(self): + self.request(1, "initialize", {"protocolVersion": "2025-11-25", "capabilities": {}, + "clientInfo": {"name": "memory-vault-test", "version": "1"}}) + self.send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}) + + def close(self): + if self.master is not None: + os.close(self.master) + elif self.process.stdin: + self.process.stdin.close() + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + self.stderr = self.process.stderr.read() + return b"".join(self.stdout), self.stderr + + +@pytest.fixture +def processes(env, tmp_path): + mod, provider, material, _state_dir, base_url = env + user_data = tmp_path / "user-data" + workdir = tmp_path / "work" + workdir.mkdir() + return mod, provider, material, user_data, workdir, child_env(user_data, base_url) + + +def test_interactive_setup_then_fresh_mcp_process_searches_e2e_automatically(processes): + mod, provider, material, user_data, workdir, environment = processes + code, stdout, stderr = run_interactive_setup(environment, workdir, material.passphrase) + assert code == 0, stderr.decode(errors="replace")[-2000:] + result = json.loads(stdout) + assert result["status"] == "provisioned" and result["passphrase_source"] == "prompt" + assert result["passphrase_stored"] is False + assert_no_leak(material, "setup stdout", stdout) + assert_no_leak(material, "setup stderr", stderr) + + # A new CLI process builds the encrypted E2E index without any passphrase. + reindexed = subprocess.run([sys.executable, str(SCRIPT), "reindex"], cwd=workdir, env=environment, + capture_output=True, timeout=120, stdin=subprocess.DEVNULL) + assert reindexed.returncode == 0, reindexed.stderr.decode(errors="replace")[-2000:] + assert json.loads(reindexed.stdout)["e2e_index"]["status"] == "applied" + assert_no_leak(material, "reindex output", reindexed.stdout + reindexed.stderr) + + # A new MCP worker: no passphrase anywhere in its environment. + client = McpProcess(environment, workdir) + try: + client.start() + whoami = client.call(2, "nap_whoami")["structuredContent"] + assert whoami["memory_secret"] == "provisioned" and "deprecations" not in whoami + found = client.call(3, "nap_discover", {"q": BODY_TERM, "mode": "local"}) + assert found["isError"] is False, found["structuredContent"] + found = found["structuredContent"] + assert [item["node_id"] for item in found["items"]] == ["secretnote"] + assert found["items"][0]["search_source"] == "local_e2e" and not found.get("warnings") + node = client.call(4, "nap_node_get", {"node_id": "secretnote"})["structuredContent"] + assert BODY in json.dumps(node) + finally: + out, err = client.close() + assert_no_leak(material, "mcp stdout", out) + assert_no_leak(material, "mcp stderr", err) + assert_state_dir_clean(material, user_data, workdir) + assert not (workdir / ".napseer").exists() + + # Removal through the CLI revokes access for the next process. + forgot = subprocess.run([sys.executable, str(SCRIPT), "gateway", "vault", "forget"], cwd=workdir, + env=environment, capture_output=True, timeout=60, stdin=subprocess.DEVNULL) + assert forgot.returncode == 0 and json.loads(forgot.stdout)["status"] == "removed" + client = McpProcess(environment, workdir) + try: + client.start() + failed = client.call(2, "nap_node_get", {"node_id": "secretnote"}) + assert failed["isError"] is True + assert failed["structuredContent"]["error"]["code"] == "memory_secret_not_provisioned" + finally: + client.close() + + +def test_mcp_worker_on_a_tty_never_prompts(processes): + _mod, _provider, material, user_data, workdir, environment = processes + client = McpProcess(environment, workdir, tty_stdin=True) + try: + client.start() + failed = client.call(2, "nap_node_get", {"node_id": "secretnote"}) + assert failed["isError"] is True + error = failed["structuredContent"]["error"] + assert error["code"] == "memory_secret_not_provisioned" + assert error["details"]["next_action"] == SETUP_COMMAND + # stdin was not consumed by a prompt: the worker keeps answering. + assert client.call(3, "nap_whoami")["structuredContent"]["memory_secret"] == "not_provisioned" + finally: + _out, err = client.close() + assert b"passphrase:" not in err.lower() + assert_state_dir_clean(material, user_data) diff --git a/tests/test_nap_install_gateway_routes.py b/tests/test_nap_install_gateway_routes.py index 5fdebd3..dd7f72b 100644 --- a/tests/test_nap_install_gateway_routes.py +++ b/tests/test_nap_install_gateway_routes.py @@ -36,6 +36,11 @@ def run(): assert mod.handle_gateway(["vault", "rotate-secret", "--kind", "memory"]) == 0 assert calls[-1] == ("gateway", ["vault", "rotate-secret", "--kind", "memory"]) + # Memory secret provisioning/removal route to the wrapper, which owns the prompt. + assert mod.handle_gateway(["vault", "setup"]) == 0 + assert calls[-1] == ("gateway", ["vault", "setup"]) + assert mod.handle_gateway(["vault", "forget"]) == 0 + assert calls[-1] == ("gateway", ["vault", "forget"]) try: mod.handle_gateway(["process", "--all"])