sibyl-memory-cli 0.1.1__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats.
5
5
  Author-email: "SIBYL, Sibyl Labs LLC" <sibyl@sibyllabs.org>
6
6
  License: MIT
@@ -18,8 +18,8 @@ Classifier: Environment :: Console
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: sibyl-memory-client>=0.3.0
22
- Requires-Dist: sibyl-memory-hermes>=0.2.0
21
+ Requires-Dist: sibyl-memory-client>=0.4.0
22
+ Requires-Dist: sibyl-memory-hermes>=0.3.2
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: pytest>=7.0; extra == "dev"
25
25
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sibyl-memory-cli"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats."
9
9
  authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
10
10
  license = { text = "MIT" }
@@ -23,8 +23,8 @@ classifiers = [
23
23
  "Topic :: Software Development :: Libraries :: Python Modules",
24
24
  ]
25
25
  dependencies = [
26
- "sibyl-memory-client>=0.3.0",
27
- "sibyl-memory-hermes>=0.2.0",
26
+ "sibyl-memory-client>=0.4.0",
27
+ "sibyl-memory-hermes>=0.3.2",
28
28
  ]
29
29
 
30
30
  [project.optional-dependencies]
@@ -13,6 +13,12 @@ All HTTP calls target https://api.sibyllabs.org/api/plugin/*.
13
13
  """
14
14
  from .cli import main
15
15
 
16
- __version__ = "0.1.0"
16
+ # Single-sourced from installed metadata so wheel + code can't drift
17
+ # (C3 audit fix v0.1.2). Same pattern as sibyl-memory-hermes v0.3.0+.
18
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
19
+ try:
20
+ __version__ = _pkg_version("sibyl-memory-cli")
21
+ except PackageNotFoundError: # pragma: no cover - source-tree dev only
22
+ __version__ = "0.0.0+source"
17
23
 
18
24
  __all__ = ["main", "__version__"]
@@ -5,7 +5,11 @@ https://api.sibyllabs.org/api/plugin/* and the local SibylMemoryProvider.
5
5
 
6
6
  Design pillars:
7
7
  - Zero non-stdlib deps in this file. urllib is enough.
8
- - Credentials are written atomically with 0600 perms.
8
+ - Credentials are written atomically at mode 0600, set at file-creation
9
+ time via O_CREAT|O_EXCL|O_NOFOLLOW (no chmod-after-write race).
10
+ - The URL parameter handed to the browser is an opaque session identifier,
11
+ not the long-lived bearer (audit SEC-1 — server-side pairing handoff
12
+ issues a separate bearer at activation completion if available).
9
13
  - session_token is never printed in full — display short slice only.
10
14
  - Polling has explicit timeouts; no infinite loops.
11
15
  - Every command exits with a clear status code (0 ok, 1 user error, 2 server error).
@@ -27,6 +31,18 @@ import webbrowser
27
31
  from pathlib import Path
28
32
  from typing import Any
29
33
 
34
+
35
+ def _client_version() -> str:
36
+ """Return the installed package version from metadata, never hardcoded."""
37
+ try:
38
+ from importlib.metadata import PackageNotFoundError, version as _v
39
+ try:
40
+ return _v("sibyl-memory-cli")
41
+ except PackageNotFoundError:
42
+ return "0.0.0+source"
43
+ except Exception:
44
+ return "0.0.0+source"
45
+
30
46
  # ---- Defaults ----------------------------------------------------------
31
47
 
32
48
  API_BASE = os.environ.get("SIBYL_API_BASE", "https://api.sibyllabs.org")
@@ -101,7 +117,7 @@ def http_request(
101
117
  """Single source of truth for HTTP calls. Returns parsed JSON or raises HttpError."""
102
118
  url = f"{API_BASE}{path}"
103
119
  data = None
104
- full_headers = {"Accept": "application/json", "User-Agent": "sibyl-memory-cli/0.1.0"}
120
+ full_headers = {"Accept": "application/json", "User-Agent": f"sibyl-memory-cli/{_client_version()}"}
105
121
  if body is not None:
106
122
  data = json.dumps(body).encode("utf-8")
107
123
  full_headers["Content-Type"] = "application/json"
@@ -124,20 +140,45 @@ def http_request(
124
140
  # ---- Credentials I/O ---------------------------------------------------
125
141
 
126
142
  def write_credentials_atomic(creds: dict, path: Path = DEFAULT_CRED_PATH) -> Path:
127
- """Write credentials.json atomically at mode 0600."""
143
+ """Write credentials.json atomically at mode 0600.
144
+
145
+ v0.1.2 hardening (audit SEC-2): mode 0600 is set by the kernel at
146
+ file-creation time via O_CREAT|O_EXCL|O_NOFOLLOW. Previously used
147
+ write_text() followed by os.chmod(), leaving a world-readable window
148
+ between syscalls every credential save.
149
+ """
128
150
  path = path.expanduser()
129
151
  path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
152
+ data = json.dumps(creds, indent=2).encode("utf-8")
130
153
  tmp = path.with_suffix(path.suffix + ".tmp")
131
- tmp.write_text(json.dumps(creds, indent=2), encoding="utf-8")
132
- os.chmod(tmp, 0o600)
133
- tmp.replace(path)
154
+ # Clean any leftover .tmp from a crashed prior write so O_EXCL can succeed.
155
+ try:
156
+ os.unlink(tmp)
157
+ except FileNotFoundError:
158
+ pass
159
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
160
+ if hasattr(os, "O_NOFOLLOW"):
161
+ flags |= os.O_NOFOLLOW
162
+ fd = os.open(str(tmp), flags, 0o600)
163
+ try:
164
+ os.write(fd, data)
165
+ os.fsync(fd)
166
+ finally:
167
+ os.close(fd)
168
+ os.replace(str(tmp), str(path))
134
169
  return path
135
170
 
136
171
 
137
172
  def read_credentials(path: Path = DEFAULT_CRED_PATH) -> dict | None:
173
+ """Read credentials.json.
174
+
175
+ v0.1.2 hardening (audit SEC-11): refuses to follow symlinks.
176
+ Returns None if the file is a symlink or doesn't exist."""
138
177
  path = path.expanduser()
139
178
  if not path.exists():
140
179
  return None
180
+ if path.is_symlink():
181
+ return None
141
182
  return json.loads(path.read_text(encoding="utf-8"))
142
183
 
143
184
 
@@ -180,10 +221,18 @@ def cmd_init(args: argparse.Namespace) -> int:
180
221
  print_status("Credentials", str(cred_path))
181
222
  return 0
182
223
 
183
- session_token = str(uuid.uuid4())
224
+ # SEC-1 mitigation (v0.1.2): the URL parameter is an opaque pairing
225
+ # session identifier, NOT the long-lived bearer used by /access and
226
+ # /check-write. The CLI generates it locally and the server treats
227
+ # it as the activation rendezvous key only. The persistent bearer
228
+ # is issued by the server in the /check response (`bearer_token`
229
+ # field) after activation completes. Servers running pre-SEC-1
230
+ # firmware that echo the URL identifier as the bearer still work —
231
+ # we use whichever the server returns in the bound credentials.
232
+ session_id = str(uuid.uuid4())
184
233
  pairing_code = _gen_pairing_code()
185
- code_hash = _hash_pairing_code(pairing_code, session_token)
186
- activate_url = f"{ACTIVATE_BASE}?session={session_token}"
234
+ code_hash = _hash_pairing_code(pairing_code, session_id)
235
+ activate_url = f"{ACTIVATE_BASE}?session={session_id}"
187
236
 
188
237
  # Pre-register the session + pairing code hash with the server.
189
238
  # The code itself never leaves the user's machine until they type it
@@ -193,12 +242,12 @@ def cmd_init(args: argparse.Namespace) -> int:
193
242
  "POST",
194
243
  "/api/plugin/session-init",
195
244
  body={
196
- "session": session_token,
245
+ "session": session_id,
197
246
  "pairing_code_hash": code_hash,
198
247
  "env": {
199
248
  "os_family": _detect_os_family(),
200
249
  "install_method": "cli",
201
- "client_version": __import__("sibyl_memory_cli").__version__,
250
+ "client_version": _client_version(),
202
251
  },
203
252
  },
204
253
  timeout=10.0,
@@ -211,7 +260,7 @@ def cmd_init(args: argparse.Namespace) -> int:
211
260
  print()
212
261
  print(bold("Sibyl Memory Plugin · activation"))
213
262
  print()
214
- print(f" {dim('Session:')} {short(session_token)}")
263
+ print(f" {dim('Session:')} {short(session_id)}")
215
264
  # Format the code with a visual break to make it easier to read off the screen
216
265
  print(f" {dim('Code:')} {bold(pairing_code[:3] + ' ' + pairing_code[3:])} {dim('(use this in the email panel)')}")
217
266
  print(f" {dim('Opening:')} {activate_url}")
@@ -232,7 +281,7 @@ def cmd_init(args: argparse.Namespace) -> int:
232
281
 
233
282
  while time.time() < deadline:
234
283
  try:
235
- resp = http_request("GET", f"/api/plugin/check?session={urllib.parse.quote(session_token)}", timeout=10.0)
284
+ resp = http_request("GET", f"/api/plugin/check?session={urllib.parse.quote(session_id)}", timeout=10.0)
236
285
  except HttpError as e:
237
286
  if e.status in (404, 503, 0):
238
287
  # Session not yet created server-side, or transient — keep polling
@@ -244,12 +293,23 @@ def cmd_init(args: argparse.Namespace) -> int:
244
293
 
245
294
  if resp.get("bound") and resp.get("credentials"):
246
295
  creds = resp["credentials"]
247
- # Sanity check: the server's session_token field MUST match what we sent
248
- if creds.get("session_token") and creds["session_token"] != session_token:
296
+ # SEC-1: prefer the server-issued bearer_token (post-fix) over
297
+ # echoing the URL pairing-session id. Servers running pre-SEC-1
298
+ # firmware echo `session_token` back as the bearer — we use
299
+ # whichever the server returns. The CLI's session_id (URL
300
+ # identifier) is the rendezvous key, not the persistent bearer.
301
+ bearer = creds.get("bearer_token") or creds.get("session_token")
302
+ if not bearer:
303
+ # Fallback: pre-SEC-1 server flow where neither field is
304
+ # echoed back — inject the pairing session id so subsequent
305
+ # /access and /check-write calls have something to send.
306
+ bearer = session_id
307
+ # Sanity check on echoed session_token (pre-SEC-1 flow only)
308
+ if creds.get("session_token") and creds["session_token"] != session_id \
309
+ and not creds.get("bearer_token"):
249
310
  print(red("\nSession token mismatch — refusing to write credentials."))
250
311
  return 2
251
- # If server didn't echo session_token, inject our locally-generated one
252
- creds.setdefault("session_token", session_token)
312
+ creds["session_token"] = bearer
253
313
  path = write_credentials_atomic(creds, cred_path)
254
314
  print(f"\r{' ' * 80}\r", end="") # clear spinner line
255
315
  print(green("✓ Activated."))
@@ -259,9 +319,18 @@ def cmd_init(args: argparse.Namespace) -> int:
259
319
  print_status("Email", creds.get("email") or "—")
260
320
  print_status("Credentials", str(path))
261
321
  print()
262
- print(dim("Wire the provider into Hermes:"))
322
+ print(dim("Activate Sibyl in Hermes:"))
323
+ print(dim(" sibyl-memory-hermes install-plugin"))
324
+ print(dim(" # then edit ~/.hermes/config.yaml:"))
325
+ print(dim(" # memory:"))
326
+ print(dim(" # provider: sibyl"))
327
+ print()
328
+ print(dim("Or use the MCP server (Claude Code / Codex / Cursor / Continue):"))
329
+ print(dim(" pip install sibyl-memory-mcp"))
330
+ print()
331
+ print(dim("Or call the SDK directly from any Python orchestration:"))
263
332
  print(dim(" from sibyl_memory_hermes import SibylMemoryProvider"))
264
- print(dim(" agent = Agent(memory=SibylMemoryProvider())"))
333
+ print(dim(" provider = SibylMemoryProvider()"))
265
334
  return 0
266
335
 
267
336
  # Spinner tick
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats.
5
5
  Author-email: "SIBYL, Sibyl Labs LLC" <sibyl@sibyllabs.org>
6
6
  License: MIT
@@ -18,8 +18,8 @@ Classifier: Environment :: Console
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: sibyl-memory-client>=0.3.0
22
- Requires-Dist: sibyl-memory-hermes>=0.2.0
21
+ Requires-Dist: sibyl-memory-client>=0.4.0
22
+ Requires-Dist: sibyl-memory-hermes>=0.3.2
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: pytest>=7.0; extra == "dev"
25
25
 
@@ -0,0 +1,5 @@
1
+ sibyl-memory-client>=0.4.0
2
+ sibyl-memory-hermes>=0.3.2
3
+
4
+ [dev]
5
+ pytest>=7.0
@@ -1,5 +0,0 @@
1
- sibyl-memory-client>=0.3.0
2
- sibyl-memory-hermes>=0.2.0
3
-
4
- [dev]
5
- pytest>=7.0