diff --git a/cli/node/CHANGELOG.md b/cli/node/CHANGELOG.md index 0f223e737..16ed4af62 100644 --- a/cli/node/CHANGELOG.md +++ b/cli/node/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to `@mem0/cli` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.9] — 2026-06-19 + +### Security + +- Telemetry no longer passes the Mem0 API key to its child process via + command-line arguments. The context is now sent over stdin, so the key is no + longer visible in the process list (`ps`, `/proc//cmdline`, Activity + Monitor). Fixes #4862. + ## [0.2.8] — 2026-06-01 ### Security diff --git a/cli/node/package.json b/cli/node/package.json index 4d0441732..8ddf5e339 100644 --- a/cli/node/package.json +++ b/cli/node/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/cli", - "version": "0.2.8", + "version": "0.2.9", "description": "The official CLI for mem0 — the memory layer for AI agents", "type": "module", "bin": { diff --git a/cli/node/src/telemetry.ts b/cli/node/src/telemetry.ts index 46a6a76fe..2d027817b 100644 --- a/cli/node/src/telemetry.ts +++ b/cli/node/src/telemetry.ts @@ -145,11 +145,11 @@ export function captureEvent( anonDistinctIdToAlias: anonIdToAlias, }; - const child = spawn( - process.execPath, - [SENDER_SCRIPT, JSON.stringify(context)], - { detached: true, stdio: "ignore" }, - ); + const child = spawn(process.execPath, [SENDER_SCRIPT], { + detached: true, + stdio: ["pipe", "ignore", "ignore"], + }); + child.stdin?.end(JSON.stringify(context)); child.unref(); } catch { /* silently swallow */ diff --git a/cli/node/telemetry-sender.cjs b/cli/node/telemetry-sender.cjs index 0343362d6..251e15df2 100644 --- a/cli/node/telemetry-sender.cjs +++ b/cli/node/telemetry-sender.cjs @@ -1,7 +1,8 @@ /** * Standalone telemetry sender — runs as a detached child process. * - * Usage: node telemetry-sender.cjs '' + * Usage: node telemetry-sender.cjs (JSON context is read from stdin; a single + * argv argument is still accepted as a legacy fallback) * * This script is spawned by telemetry.captureEvent() and runs independently * of the parent CLI process. It: @@ -19,6 +20,31 @@ const https = require("https"); const fs = require("fs"); +function loadContext() { + return new Promise((resolve, reject) => { + if (process.argv[2]) { + try { + resolve(JSON.parse(process.argv[2])); + } catch (err) { + reject(err); + } + return; + } + + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => (data += chunk)); + process.stdin.on("end", () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(err); + } + }); + process.stdin.on("error", reject); + }); +} + function httpsRequest(url, method, headers, body) { return new Promise((resolve, reject) => { const u = new URL(url); @@ -108,7 +134,7 @@ async function sendIdentifyEvent(ctx, payload, anonId) { } async function main() { - const ctx = JSON.parse(process.argv[2]); + const ctx = await loadContext(); const payload = ctx.payload; if (ctx.needsEmail && ctx.mem0ApiKey) { diff --git a/cli/node/tests/telemetry.test.ts b/cli/node/tests/telemetry.test.ts new file mode 100644 index 000000000..6ff5ea1af --- /dev/null +++ b/cli/node/tests/telemetry.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockLoadConfig = vi.fn(); +const mockSaveConfig = vi.fn(); +const mockSpawn = vi.fn(); + +vi.mock("../src/config.js", () => ({ + CONFIG_FILE: "/tmp/mem0-config.json", + loadConfig: mockLoadConfig, + saveConfig: mockSaveConfig, +})); + +vi.mock("node:child_process", () => ({ + spawn: mockSpawn, +})); + +describe("captureEvent", () => { + beforeEach(() => { + vi.resetModules(); + mockLoadConfig.mockReset(); + mockSaveConfig.mockReset(); + mockSpawn.mockReset(); + delete process.env.MEM0_TELEMETRY; + }); + + it("pipes the telemetry context through stdin instead of argv", async () => { + mockLoadConfig.mockReturnValue({ + platform: { + apiKey: "m0-node-secret", + baseUrl: "https://api.mem0.ai", + userEmail: "", + }, + telemetry: { + anonymousId: "cli-anon-node", + }, + }); + + const stdin = { end: vi.fn() }; + const child = { stdin, unref: vi.fn() }; + mockSpawn.mockReturnValue(child); + + const { captureEvent } = await import("../src/telemetry.js"); + captureEvent("node_test_event", { case: "stdin-secret" }); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + const [execPath, args, options] = mockSpawn.mock.calls[0]; + expect(execPath).toBe(process.execPath); + expect(args).toHaveLength(1); + expect(String(args[0])).toContain("telemetry-sender.cjs"); + expect(JSON.stringify(args)).not.toContain("m0-node-secret"); + expect(options).toMatchObject({ detached: true, stdio: ["pipe", "ignore", "ignore"] }); + + expect(stdin.end).toHaveBeenCalledTimes(1); + const payload = JSON.parse(stdin.end.mock.calls[0][0]); + expect(payload.mem0ApiKey).toBe("m0-node-secret"); + expect(payload.payload.event).toBe("node_test_event"); + expect(child.unref).toHaveBeenCalledTimes(1); + }); +}); diff --git a/cli/python/CHANGELOG.md b/cli/python/CHANGELOG.md index e7d9af989..8328c6031 100644 --- a/cli/python/CHANGELOG.md +++ b/cli/python/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to `mem0-cli` (Python) are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.8] — 2026-06-19 + +### Security + +- Telemetry no longer passes the Mem0 API key to its child process via + command-line arguments. The context is now sent over stdin, so the key is no + longer visible in the process list (`ps`, `/proc//cmdline`, Activity + Monitor). Fixes #4862. + +### Fixed + +- `__version__` now matches the packaged version (was stale at 0.2.4). + ## [0.2.7] — 2026-05-20 ### Added diff --git a/cli/python/pyproject.toml b/cli/python/pyproject.toml index 920f61e86..df922e13b 100644 --- a/cli/python/pyproject.toml +++ b/cli/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mem0-cli" -version = "0.2.7" +version = "0.2.8" description = "The official CLI for mem0 — the memory layer for AI agents" readme = "README.md" license = "Apache-2.0" diff --git a/cli/python/src/mem0_cli/__init__.py b/cli/python/src/mem0_cli/__init__.py index d8f12d640..71cd651de 100644 --- a/cli/python/src/mem0_cli/__init__.py +++ b/cli/python/src/mem0_cli/__init__.py @@ -1,3 +1,3 @@ """mem0 CLI — the command-line interface for the mem0 memory layer.""" -__version__ = "0.2.4" +__version__ = "0.2.8" diff --git a/cli/python/src/mem0_cli/telemetry.py b/cli/python/src/mem0_cli/telemetry.py index 5c8e0e4ff..aed7df108 100644 --- a/cli/python/src/mem0_cli/telemetry.py +++ b/cli/python/src/mem0_cli/telemetry.py @@ -137,12 +137,19 @@ def capture_event( "anon_distinct_id_to_alias": anon_id_to_alias, } - subprocess.Popen( - [sys.executable, "-m", "mem0_cli.telemetry_sender", json.dumps(context)], + child = subprocess.Popen( + [sys.executable, "-m", "mem0_cli.telemetry_sender"], + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True, + text=True, ) + if child.stdin: + with contextlib.suppress(Exception): + child.stdin.write(json.dumps(context)) + with contextlib.suppress(Exception): + child.stdin.close() except Exception: pass diff --git a/cli/python/src/mem0_cli/telemetry_sender.py b/cli/python/src/mem0_cli/telemetry_sender.py index 5786b678b..3655cb8cf 100644 --- a/cli/python/src/mem0_cli/telemetry_sender.py +++ b/cli/python/src/mem0_cli/telemetry_sender.py @@ -1,6 +1,7 @@ """Standalone telemetry sender — runs as a detached subprocess. -Usage: python -m mem0_cli.telemetry_sender '' +Usage: python -m mem0_cli.telemetry_sender (JSON context is read from stdin; +a single argv argument is still accepted as a legacy fallback) This module is spawned by telemetry.capture_event() and runs independently of the parent CLI process. It: @@ -20,8 +21,18 @@ import sys import urllib.request +def _load_context() -> dict: + """Load telemetry context from stdin, falling back to argv for compatibility.""" + raw = "" + if not sys.stdin.isatty(): + raw = sys.stdin.read().strip() + if not raw and len(sys.argv) > 1: + raw = sys.argv[1] + return json.loads(raw) + + def main() -> None: - ctx = json.loads(sys.argv[1]) + ctx = _load_context() payload = ctx["payload"] if ctx.get("needs_email") and ctx.get("mem0_api_key"): diff --git a/cli/python/tests/test_telemetry.py b/cli/python/tests/test_telemetry.py new file mode 100644 index 000000000..09e76c75f --- /dev/null +++ b/cli/python/tests/test_telemetry.py @@ -0,0 +1,80 @@ +"""Tests for telemetry subprocess secret handling.""" + +from __future__ import annotations + +import io +import json +import subprocess +import sys + +from mem0_cli.config import Mem0Config, save_config +from mem0_cli.telemetry import capture_event +from mem0_cli.telemetry_sender import _load_context + + +class _CaptureStdin: + def __init__(self): + self.buffer = "" + self.closed = False + + def write(self, value: str) -> None: + self.buffer += value + + def close(self) -> None: + self.closed = True + + +class _DummyProcess: + def __init__(self): + self.stdin = _CaptureStdin() + + +def test_capture_event_writes_context_to_stdin_not_argv(isolate_config, monkeypatch): + config = Mem0Config() + config.platform.api_key = "m0-test-secret" + config.telemetry.anonymous_id = "cli-anon-test" + save_config(config) + + captured: dict[str, object] = {} + proc = _DummyProcess() + + def fake_popen(args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return proc + + monkeypatch.setattr("mem0_cli.telemetry.subprocess.Popen", fake_popen) + + capture_event("unit_test_event", {"case": "stdin-secret"}) + + argv = captured["args"] + assert argv == [sys.executable, "-m", "mem0_cli.telemetry_sender"] + assert all("m0-test-secret" not in arg for arg in argv) + + kwargs = captured["kwargs"] + assert kwargs["stdin"] == subprocess.PIPE + assert kwargs["text"] is True + + ctx = json.loads(proc.stdin.buffer) + assert ctx["mem0_api_key"] == "m0-test-secret" + assert ctx["payload"]["event"] == "unit_test_event" + + assert proc.stdin.closed + + +def test_load_context_reads_from_stdin(monkeypatch): + monkeypatch.setattr("sys.argv", ["telemetry_sender"]) + monkeypatch.setattr("sys.stdin", io.StringIO('{"payload": {"event": "stdin"}}')) + + ctx = _load_context() + + assert ctx["payload"]["event"] == "stdin" + + +def test_load_context_falls_back_to_argv(monkeypatch): + monkeypatch.setattr("sys.argv", ["telemetry_sender", '{"payload": {"event": "argv"}}']) + monkeypatch.setattr("sys.stdin", io.StringIO("")) + + ctx = _load_context() + + assert ctx["payload"]["event"] == "argv"