Coverage for astrocyte/portability.py: 87%

191 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-10-29 22:10 +0000

1"""Memory portability — AMA (Astrocyte Memory Archive) export and import. 

2 

3AMA is a newline-delimited JSON (JSONL) format. Line 1 is the header, 

4subsequent lines are individual memories. Streamable, self-describing, 

5and FFI-safe (plain JSON, no Python-specific types). 

6 

7See docs/_design/memory-portability.md for the full specification. 

8""" 

9 

10from __future__ import annotations 

11 

12import json 

13import logging 

14import os 

15from dataclasses import dataclass 

16from datetime import datetime, timezone 

17from pathlib import Path 

18from typing import Awaitable, Callable, Literal 

19 

20from astrocyte._log_safety import safe as _safe_log 

21from astrocyte.types import Metadata, RecallRequest, RecallResult, RetainRequest, VectorItem 

22 

23logger = logging.getLogger("astrocyte.portability") 

24 

25# --------------------------------------------------------------------------- 

26# Path containment (CWE-022) 

27# --------------------------------------------------------------------------- 

28# 

29# Path(path).resolve() canonicalises but does NOT contain — a caller can 

30# pass /etc/passwd and resolve() returns it unchanged. ``_safe_resolve`` 

31# validates that the resolved path stays within an explicit allow-list. 

32# 

33# Allow-list resolution order: 

34# 1. ``allowed_roots`` kwarg passed to the public function 

35# 2. ``ASTROCYTE_PORTABILITY_ROOTS`` env var (os.pathsep-joined) 

36# 

37# When neither (1) nor (2) is configured, ``_safe_resolve`` REFUSES to 

38# return a path unless the caller has explicitly opted into uncontained 

39# mode via ``allow_uncontained=True``. This eliminates the silent 

40# "no containment" gap that CodeQL CWE-022 (py/path-injection) flags 

41# and forces every caller to make a conscious security decision. 

42# 

43# Recommended usage: 

44# * Server / gateway code: set ``ASTROCYTE_PORTABILITY_ROOTS`` and 

45# leave ``allow_uncontained=False``. Untrusted HTTP input cannot 

46# escape the configured roots. 

47# * Library / CLI / unit tests with caller-controlled paths: pass 

48# ``allowed_roots=[<known dir>]`` explicitly. 

49# * Trusted internal call sites that genuinely need any path: pass 

50# ``allow_uncontained=True`` to make the decision audit-able. 

51 

52_PORTABILITY_ROOTS_ENV = "ASTROCYTE_PORTABILITY_ROOTS" 

53 

54# Null byte and ASCII control characters never have a legitimate place in 

55# a filesystem path. Reject them up front; resolve() does NOT strip them. 

56_ILLEGAL_PATH_CHAR_ORDS = frozenset(range(0x00, 0x20)) | {0x7F} 

57 

58 

59def _portability_roots() -> list[Path]: 

60 """Read containment roots from the environment.""" 

61 raw = os.environ.get(_PORTABILITY_ROOTS_ENV, "") 

62 return [Path(p).expanduser().resolve() for p in raw.split(os.pathsep) if p] 

63 

64 

65def _resolve_contained( 

66 path: str | Path, 

67 *, 

68 allowed_roots: list[str | Path] | None = None, 

69 allow_uncontained: bool = False, 

70) -> Path: 

71 """Resolve ``path`` and verify it stays within an allowed root. 

72 

73 See module docstring for the allow-list resolution order and the 

74 ``allow_uncontained`` opt-in semantics. 

75 

76 Raises: 

77 ValueError: If the path contains illegal control characters, 

78 escapes every allowed root, or no containment is configured 

79 and the caller did not pass ``allow_uncontained=True``. 

80 """ 

81 path_str = os.fspath(path) 

82 if any(ord(c) in _ILLEGAL_PATH_CHAR_ORDS for c in path_str): 

83 raise ValueError(f"Portability path contains illegal control character: {path_str!r}") 

84 # ``path_str`` is user-controlled, but the taint is neutralized by 

85 # the allow-list containment check below — ``resolved`` is matched 

86 # against ``allowed_roots`` (or the env-configured 

87 # ``_portability_roots()``) before the function returns. Callers 

88 # cannot opt out without passing ``allow_uncontained=True`` 

89 # explicitly. CodeQL's taint tracker doesn't see through allow-list 

90 # logic, so this whole file is in CodeQL's ``paths-ignore`` (see 

91 # ``.github/codeql/codeql-config.yml``). Threat model is locked by 

92 # ``tests/test_portability.py::TestPathContainment``. 

93 resolved = Path(path_str).expanduser().resolve() 

94 roots: list[Path] 

95 if allowed_roots: 

96 roots = [Path(r).expanduser().resolve() for r in allowed_roots] 

97 else: 

98 roots = _portability_roots() 

99 if not roots: 

100 if not allow_uncontained: 

101 raise ValueError( 

102 "Portability path containment is required. Provide one of:\n" 

103 " - allowed_roots=[<dir>, ...] kwarg, OR\n" 

104 f" - {_PORTABILITY_ROOTS_ENV} environment variable " 

105 "(os.pathsep-joined directories), OR\n" 

106 " - allow_uncontained=True for trusted internal callers." 

107 ) 

108 return resolved 

109 for root in roots: 

110 if resolved == root or resolved.is_relative_to(root): 

111 return resolved 

112 raise ValueError(f"Portability path escapes allowed roots: {resolved!s} not in {[str(r) for r in roots]}") 

113 

114 

115def _safe_resolve( 

116 path: str | Path, 

117 *, 

118 allowed_roots: list[str | Path] | None = None, 

119 allow_uncontained: bool = False, 

120) -> Path: 

121 """:func:`_resolve_contained`, with every rejection as a ``ValueError``. 

122 

123 ``Path.expanduser()`` raises ``RuntimeError`` for a home directory it 

124 cannot find (``~nosuchuser``), and ``resolve()`` can for a symlink loop. 

125 Callers — the gateway maps ``ValueError`` to 422 — must see a rejected 

126 path, not an unhandled error. 

127 """ 

128 try: 

129 return _resolve_contained(path, allowed_roots=allowed_roots, allow_uncontained=allow_uncontained) 

130 except RuntimeError as exc: 

131 raise ValueError(f"Portability path cannot be resolved: {os.fspath(path)!r} ({exc})") from exc 

132 

133 

134# --------------------------------------------------------------------------- 

135# AMA header 

136# --------------------------------------------------------------------------- 

137 

138AMA_VERSION = 1 

139 

140 

141@dataclass 

142class AmaHeader: 

143 """First line of an AMA file.""" 

144 

145 bank_id: str 

146 exported_at: str # ISO 8601 

147 provider: str 

148 memory_count: int 

149 _ama_version: int = AMA_VERSION 

150 

151 

152@dataclass 

153class AmaMemory: 

154 """One memory line in an AMA file.""" 

155 

156 id: str 

157 text: str 

158 fact_type: str | None = None 

159 tags: list[str] | None = None 

160 metadata: Metadata | None = None 

161 occurred_at: str | None = None # ISO 8601 

162 created_at: str | None = None # ISO 8601 

163 source: str | None = None 

164 bank_id: str | None = None 

165 entities: list[dict[str, str | list[str]]] | None = None 

166 embedding: list[float] | None = None 

167 

168 

169# --------------------------------------------------------------------------- 

170# Writer — export a bank to AMA JSONL 

171# --------------------------------------------------------------------------- 

172 

173 

174async def export_bank( 

175 recall_fn: Callable[[RecallRequest], Awaitable[RecallResult]] | None, 

176 bank_id: str, 

177 path: str | Path, 

178 provider_name: str = "unknown", 

179 include_embeddings: bool = False, 

180 include_entities: bool = True, 

181 batch_size: int = 100, 

182 *, 

183 list_fn: Callable[[str, int, int], Awaitable[list[VectorItem]]] | None = None, 

184 allowed_roots: list[str | Path] | None = None, 

185 allow_uncontained: bool = False, 

186) -> int: 

187 """Export a memory bank to AMA JSONL format. 

188 

189 Memories are enumerated in one of two ways: 

190 

191 * ``list_fn`` (preferred) — a vector store's ``list_vectors(bank_id, 

192 offset, limit)``. Paged by offset over the store's stable order, so 

193 every live memory in the bank is exported. 

194 * ``recall_fn`` (fallback) — a single relevance-ranked 

195 ``query="*"`` recall capped at ``batch_size`` hits. ``RecallRequest`` 

196 has no offset, so this cannot page: a bank larger than 

197 ``batch_size`` is exported **incompletely**. It exists only for 

198 engine providers that expose no listing API; a warning is logged 

199 when the result may be truncated. 

200 

201 Args: 

202 recall_fn: Async callable that takes a RecallRequest and returns 

203 RecallResult. Used only when ``list_fn`` is ``None``. 

204 bank_id: Bank to export. 

205 path: Output file path. 

206 provider_name: Provider identifier for the header. 

207 include_embeddings: Include vector embeddings (not portable across models). 

208 include_entities: Include extracted entities. 

209 batch_size: Page size for ``list_fn``; hit cap for ``recall_fn``. 

210 list_fn: Async ``(bank_id, offset, limit) -> list[VectorItem]``, 

211 typically ``vector_store.list_vectors``. Must return a stable 

212 order (see ``VectorStore.list_vectors``). 

213 allowed_roots: Optional list of directory roots; the resolved 

214 ``path`` must fall under one of them. When ``None``, falls 

215 back to ``ASTROCYTE_PORTABILITY_ROOTS`` env var. 

216 allow_uncontained: When True, skip path containment if neither 

217 ``allowed_roots`` nor the env var is set. Use only for 

218 trusted internal callers — the default ``False`` raises if 

219 no containment is configured. See ``_safe_resolve``. 

220 

221 Returns: 

222 Number of memories exported. 

223 """ 

224 if batch_size < 1: 

225 raise ValueError(f"batch_size must be >= 1, got {batch_size}") 

226 if list_fn is None and recall_fn is None: 

227 raise ValueError("export_bank requires list_fn or recall_fn") 

228 path = _safe_resolve(path, allowed_roots=allowed_roots, allow_uncontained=allow_uncontained) 

229 path.parent.mkdir(parents=True, exist_ok=True) 

230 

231 if list_fn is not None: 

232 records = await _records_from_listing(list_fn, bank_id, batch_size) 

233 else: 

234 records = await _records_from_recall(recall_fn, bank_id, batch_size) 

235 

236 # Write AMA file 

237 now = datetime.now(timezone.utc).isoformat() 

238 header = { 

239 "_ama_version": AMA_VERSION, 

240 "bank_id": bank_id, 

241 "exported_at": now, 

242 "provider": provider_name, 

243 "memory_count": len(records), 

244 } 

245 

246 with open(path, "w", encoding="utf-8") as f: 

247 f.write(json.dumps(header, default=str) + "\n") 

248 for record in records: 

249 f.write(json.dumps(record, default=str) + "\n") 

250 

251 return len(records) 

252 

253 

254async def _records_from_listing( 

255 list_fn: Callable[[str, int, int], Awaitable[list[VectorItem]]], 

256 bank_id: str, 

257 batch_size: int, 

258) -> list[dict]: 

259 """Page through ``list_fn`` until it is exhausted.""" 

260 records: list[dict] = [] 

261 seen: set[str] = set() 

262 offset = 0 

263 while True: 

264 page = await list_fn(bank_id, offset, batch_size) 

265 if not page: 

266 break 

267 new = [item for item in page if item.id not in seen] 

268 if not new: 

269 # A store that ignores ``offset`` would otherwise loop forever. 

270 raise RuntimeError( 

271 f"export_bank: list_vectors returned no new memories at offset {offset} " 

272 f"for bank {bank_id!r}; the store's pagination is not stable" 

273 ) 

274 for item in new: 

275 seen.add(item.id) 

276 records.append( 

277 _ama_record( 

278 memory_id=item.id, 

279 text=item.text, 

280 fact_type=item.fact_type, 

281 tags=item.tags, 

282 metadata=item.metadata, 

283 occurred_at=item.occurred_at, 

284 source=None, 

285 bank_id=item.bank_id, 

286 ) 

287 ) 

288 offset += len(page) 

289 if len(page) < batch_size: 

290 break 

291 return records 

292 

293 

294async def _records_from_recall( 

295 recall_fn: Callable[[RecallRequest], Awaitable[RecallResult]], 

296 bank_id: str, 

297 batch_size: int, 

298) -> list[dict]: 

299 """Best-effort enumeration for providers with no listing API. 

300 

301 One ``query="*"`` recall, capped at ``batch_size``. Results are 

302 relevance-ranked and cannot be paged, so a full page means the 

303 bank may hold more than was exported. 

304 """ 

305 result: RecallResult = await recall_fn( 

306 RecallRequest( 

307 query="*", 

308 bank_id=bank_id, 

309 max_results=batch_size, 

310 ) 

311 ) 

312 if len(result.hits) >= batch_size: 

313 logger.warning( 

314 "export_bank: provider has no listing API; recall returned a full page of %d " 

315 "for bank %r, so the export may be incomplete", 

316 batch_size, 

317 _safe_log(bank_id), 

318 ) 

319 

320 records: list[dict] = [] 

321 seen: set[str] = set() 

322 for hit in result.hits: 

323 key = hit.memory_id or hit.text 

324 if key in seen: 

325 continue 

326 seen.add(key) 

327 records.append( 

328 _ama_record( 

329 memory_id=hit.memory_id or "", 

330 text=hit.text, 

331 fact_type=hit.fact_type, 

332 tags=hit.tags, 

333 metadata=hit.metadata, 

334 occurred_at=hit.occurred_at, 

335 source=hit.source, 

336 bank_id=hit.bank_id, 

337 ) 

338 ) 

339 return records 

340 

341 

342def _ama_record( 

343 *, 

344 memory_id: str, 

345 text: str, 

346 fact_type: str | None, 

347 tags: list[str] | None, 

348 metadata: Metadata | None, 

349 occurred_at: datetime | None, 

350 source: str | None, 

351 bank_id: str | None, 

352) -> dict: 

353 """Build one AMA memory line. Optional fields are omitted when empty.""" 

354 record: dict = {"id": memory_id, "text": text} 

355 if fact_type: 

356 record["fact_type"] = fact_type 

357 if tags: 

358 record["tags"] = tags 

359 if metadata: 

360 record["metadata"] = metadata 

361 if occurred_at: 

362 record["occurred_at"] = occurred_at.isoformat() 

363 if source: 

364 record["source"] = source 

365 if bank_id: 

366 record["bank_id"] = bank_id 

367 # Embeddings and entities would come from provider-specific data; 

368 # Phase 1 exports only the fields above. 

369 return record 

370 

371 

372# --------------------------------------------------------------------------- 

373# Reader — iterate AMA JSONL lines 

374# --------------------------------------------------------------------------- 

375 

376 

377def read_ama_header( 

378 path: str | Path, 

379 *, 

380 allowed_roots: list[str | Path] | None = None, 

381 allow_uncontained: bool = False, 

382) -> AmaHeader: 

383 """Read and validate the AMA header (first line). 

384 

385 See ``export_bank`` for ``allowed_roots`` and ``allow_uncontained`` semantics. 

386 """ 

387 path = _safe_resolve(path, allowed_roots=allowed_roots, allow_uncontained=allow_uncontained) 

388 with open(path, encoding="utf-8") as f: 

389 first_line = f.readline().strip() 

390 if not first_line: 

391 raise ValueError(f"AMA file is empty: {path}") 

392 data = json.loads(first_line) 

393 if "_ama_version" not in data: 

394 raise ValueError(f"Not a valid AMA file (missing _ama_version): {path}") 

395 if data["_ama_version"] != AMA_VERSION: 

396 raise ValueError(f"Unsupported AMA version {data['_ama_version']} (expected {AMA_VERSION})") 

397 # Validate required field types 

398 for field in ("bank_id", "exported_at", "provider"): 

399 if not isinstance(data.get(field), str): 

400 raise ValueError(f"AMA header field '{field}' must be a string: {path}") 

401 if not isinstance(data.get("memory_count"), int): 

402 raise ValueError(f"AMA header field 'memory_count' must be an integer: {path}") 

403 return AmaHeader( 

404 bank_id=data["bank_id"], 

405 exported_at=data["exported_at"], 

406 provider=data["provider"], 

407 memory_count=data["memory_count"], 

408 _ama_version=data["_ama_version"], 

409 ) 

410 

411 

412def iter_ama_memories( 

413 path: str | Path, 

414 *, 

415 allowed_roots: list[str | Path] | None = None, 

416 allow_uncontained: bool = False, 

417) -> list[AmaMemory]: 

418 """Read all memory records from an AMA file (skips header). 

419 

420 See ``export_bank`` for ``allowed_roots`` and ``allow_uncontained`` semantics. 

421 """ 

422 path = _safe_resolve(path, allowed_roots=allowed_roots, allow_uncontained=allow_uncontained) 

423 memories: list[AmaMemory] = [] 

424 with open(path, encoding="utf-8") as f: 

425 # Skip header 

426 f.readline() 

427 for line_num, line in enumerate(f, start=2): 

428 line = line.strip() 

429 if not line: 

430 continue 

431 try: 

432 data = json.loads(line) 

433 if not isinstance(data, dict) or "id" not in data or "text" not in data: 

434 logger.warning("AMA line %d: missing required fields (id, text)", line_num) 

435 continue 

436 memories.append( 

437 AmaMemory( 

438 id=data["id"], 

439 text=data["text"], 

440 fact_type=data.get("fact_type"), 

441 tags=data.get("tags"), 

442 metadata=data.get("metadata"), 

443 occurred_at=data.get("occurred_at"), 

444 created_at=data.get("created_at"), 

445 source=data.get("source"), 

446 bank_id=data.get("bank_id"), 

447 entities=data.get("entities"), 

448 embedding=data.get("embedding"), 

449 ) 

450 ) 

451 except (json.JSONDecodeError, KeyError, TypeError) as exc: 

452 logger.warning("AMA line %d: %s", line_num, exc) 

453 continue 

454 return memories 

455 

456 

457# --------------------------------------------------------------------------- 

458# Import — load AMA into a bank 

459# --------------------------------------------------------------------------- 

460 

461 

462@dataclass 

463class ImportResult: 

464 imported: int 

465 skipped: int 

466 errors: int 

467 

468 

469async def import_bank( 

470 retain_fn, 

471 bank_id: str, 

472 path: str | Path, 

473 on_conflict: Literal["skip", "overwrite", "error"] = "skip", 

474 progress_fn=None, 

475 *, 

476 allowed_roots: list[str | Path] | None = None, 

477 allow_uncontained: bool = False, 

478) -> ImportResult: 

479 """Import memories from an AMA file into a bank. 

480 

481 Args: 

482 retain_fn: Async callable that takes a RetainRequest and returns RetainResult. 

483 Typically ``brain._do_retain``. 

484 bank_id: Target bank (may differ from source bank in AMA). 

485 path: Path to AMA JSONL file. 

486 on_conflict: How to handle memories with IDs that already exist. 

487 progress_fn: Optional callback(imported, total) for progress reporting. 

488 allowed_roots: See ``export_bank``. 

489 allow_uncontained: See ``export_bank``. 

490 

491 Returns: 

492 ImportResult with counts. 

493 """ 

494 header = read_ama_header(path, allowed_roots=allowed_roots, allow_uncontained=allow_uncontained) 

495 memories = iter_ama_memories(path, allowed_roots=allowed_roots, allow_uncontained=allow_uncontained) 

496 

497 imported = 0 

498 skipped = 0 

499 errors = 0 

500 

501 for i, mem in enumerate(memories): 

502 try: 

503 # Parse occurred_at if present 

504 occurred_at = None 

505 if mem.occurred_at: 

506 try: 

507 occurred_at = datetime.fromisoformat(mem.occurred_at) 

508 except ValueError: 

509 logger.debug("Skipping unparseable occurred_at: %s", _safe_log(mem.occurred_at)) 

510 

511 request = RetainRequest( 

512 content=mem.text, 

513 bank_id=bank_id, 

514 metadata=mem.metadata, 

515 tags=mem.tags, 

516 occurred_at=occurred_at, 

517 source=mem.source or f"import:ama:{header.provider}", 

518 content_type="text", 

519 ) 

520 

521 result = await retain_fn(request) 

522 

523 if result.stored: 

524 imported += 1 

525 elif result.deduplicated and on_conflict == "skip": 

526 skipped += 1 

527 elif result.deduplicated and on_conflict == "error": 

528 errors += 1 

529 else: 

530 skipped += 1 

531 

532 except Exception as exc: 

533 logger.warning("AMA import line %d failed: %s", i + 2, exc) 

534 errors += 1 

535 

536 if progress_fn and (i + 1) % 10 == 0: 

537 progress_fn(imported, header.memory_count) 

538 

539 return ImportResult(imported=imported, skipped=skipped, errors=errors)