loki-mode 7.95.0 → 7.97.0

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.
@@ -269,6 +269,7 @@ class MemoryRetrieval:
269
269
  vector_indices: Optional[Dict[str, VectorIndex]] = None,
270
270
  base_path: str = ".loki/memory",
271
271
  namespace: Optional[str] = None,
272
+ include_unstamped_legacy: bool = False,
272
273
  ):
273
274
  """
274
275
  Initialize the memory retrieval system.
@@ -279,12 +280,20 @@ class MemoryRetrieval:
279
280
  vector_indices: Optional dict of vector indices (episodic, semantic, skills)
280
281
  base_path: Base path for memory storage directory
281
282
  namespace: Optional namespace for scoped retrieval
283
+ include_unstamped_legacy: Opt-in escape hatch. When False (the
284
+ secure default), legacy entries that lack a "_namespace" stamp
285
+ are EXCLUDED from a namespaced query. This prevents a silent
286
+ cross-namespace leak where one project reads another project's
287
+ unstamped memory. Set True only when migrating a single-project
288
+ store whose entries predate namespace stamping and you have
289
+ verified every entry belongs to the active namespace.
282
290
  """
283
291
  self.storage = storage
284
292
  self.embedding_engine = embedding_engine
285
293
  self.vector_indices = vector_indices or {}
286
294
  self.base_path = Path(base_path)
287
295
  self._namespace = namespace
296
+ self._include_unstamped_legacy = include_unstamped_legacy
288
297
  # Track when indices were last built to detect staleness (BUG-MEM-002).
289
298
  # When consolidation modifies patterns, indices become stale and should
290
299
  # be rebuilt before the next similarity search.
@@ -318,25 +327,45 @@ class MemoryRetrieval:
318
327
  Behavior:
319
328
  - If self._namespace is None, accept all (backward compat for unscoped retrieval).
320
329
  - If result has "_namespace" matching, accept.
321
- - If result lacks "_namespace" (legacy entry written before stamping),
322
- log a deprecation warning (rate-limited) and ACCEPT for backward compat.
323
- - Otherwise, reject.
330
+ - If result lacks "_namespace" (legacy entry written before stamping):
331
+ treat it conservatively. An unstamped entry has no provable origin,
332
+ so under a namespaced query it could belong to ANY namespace and
333
+ including it is a silent cross-namespace leak (one project reading
334
+ another's memory). By default (self._include_unstamped_legacy is
335
+ False) such entries are EXCLUDED, with a rate-limited warning telling
336
+ operators to re-save the entry to add a stamp. Operators who have
337
+ verified a single-project store predates stamping can opt back in via
338
+ include_unstamped_legacy=True.
339
+ - Otherwise (stamp present but does not match), reject.
324
340
  """
325
341
  if self._namespace is None:
326
342
  return True
327
343
  result_ns = result.get("_namespace")
328
344
  if result_ns is None:
329
- # Legacy entry without namespace stamp; accept for backward compat
330
- # but warn so operators can re-save to add stamps.
345
+ # Legacy entry without namespace stamp. No provable origin -> do not
346
+ # silently leak it across namespaces. Exclude by default; warn so
347
+ # operators can re-save to add a stamp (rate-limited to avoid spam).
331
348
  if MemoryRetrieval._legacy_warned_count < MemoryRetrieval._LEGACY_WARN_LIMIT:
332
- logger.warning(
333
- "Memory entry id=%s lacks '_namespace' stamp (legacy "
334
- "entry). Including in results for backward compatibility. "
335
- "Re-save this entry to enable namespace isolation.",
336
- result.get("id", "<unknown>"),
337
- )
349
+ if self._include_unstamped_legacy:
350
+ logger.warning(
351
+ "Memory entry id=%s lacks '_namespace' stamp (legacy "
352
+ "entry). Including under namespace=%s because "
353
+ "include_unstamped_legacy is set. Re-save this entry to "
354
+ "stamp it and remove the opt-in.",
355
+ result.get("id", "<unknown>"),
356
+ self._namespace,
357
+ )
358
+ else:
359
+ logger.warning(
360
+ "Memory entry id=%s lacks '_namespace' stamp (legacy "
361
+ "entry). Excluding from namespace=%s query to prevent a "
362
+ "cross-namespace leak. Re-save this entry to stamp it, "
363
+ "or pass include_unstamped_legacy=True to opt in.",
364
+ result.get("id", "<unknown>"),
365
+ self._namespace,
366
+ )
338
367
  MemoryRetrieval._legacy_warned_count += 1
339
- return True
368
+ return self._include_unstamped_legacy
340
369
  return result_ns == self._namespace
341
370
 
342
371
  def with_namespace(self, namespace: str) -> "MemoryRetrieval":
@@ -361,6 +390,7 @@ class MemoryRetrieval:
361
390
  vector_indices=self.vector_indices,
362
391
  base_path=str(self.base_path),
363
392
  namespace=namespace,
393
+ include_unstamped_legacy=self._include_unstamped_legacy,
364
394
  )
365
395
 
366
396
  # -------------------------------------------------------------------------
package/memory/storage.py CHANGED
@@ -33,6 +33,29 @@ except ImportError:
33
33
  # Default namespace constant
34
34
  DEFAULT_NAMESPACE = "default"
35
35
 
36
+ # Allowed namespace characters. A namespace becomes a single path segment under
37
+ # the memory root, so it must not contain separators, traversal, or whitespace.
38
+ _NAMESPACE_RE = re.compile(r'^[a-zA-Z0-9_-]+$')
39
+
40
+
41
+ def _validate_namespace_charset(namespace: str) -> None:
42
+ """Reject a namespace whose characters could escape its directory.
43
+
44
+ Charset-only check, shared by ``__init__`` and ``with_namespace`` so the two
45
+ validation sites cannot drift. Callers are responsible for deciding whether a
46
+ None/empty namespace is acceptable (it is in ``__init__`` for backward
47
+ compat; it is rejected in ``with_namespace``); this only runs once a concrete
48
+ non-default namespace string is present.
49
+
50
+ Raises:
51
+ ValueError: If the namespace contains anything outside [A-Za-z0-9_-].
52
+ """
53
+ if not _NAMESPACE_RE.match(namespace):
54
+ raise ValueError(
55
+ f"Invalid namespace '{namespace}': "
56
+ "only alphanumeric characters, hyphens, and underscores are allowed"
57
+ )
58
+
36
59
 
37
60
  class MemoryStorage:
38
61
  """
@@ -73,14 +96,11 @@ class MemoryStorage:
73
96
  self._root_path = Path(effective_base)
74
97
  self._namespace = namespace
75
98
 
76
- # Validate namespace to prevent path traversal
99
+ # Validate namespace to prevent path traversal. None/empty is accepted
100
+ # here for backward compat (it selects the default, un-namespaced root);
101
+ # only a concrete non-default namespace is charset-checked.
77
102
  if namespace and namespace != DEFAULT_NAMESPACE:
78
- import re
79
- if not re.match(r'^[a-zA-Z0-9_-]+$', namespace):
80
- raise ValueError(
81
- f"Invalid namespace '{namespace}': "
82
- "only alphanumeric characters, hyphens, and underscores are allowed"
83
- )
103
+ _validate_namespace_charset(namespace)
84
104
 
85
105
  # Calculate effective base path (with namespace if specified)
86
106
  if namespace and namespace != DEFAULT_NAMESPACE:
@@ -119,15 +139,21 @@ class MemoryStorage:
119
139
  New MemoryStorage instance for the specified namespace
120
140
 
121
141
  Raises:
122
- ValueError: If namespace contains path traversal characters
123
- """
124
- import re
125
- if namespace and namespace != DEFAULT_NAMESPACE:
126
- if not re.match(r'^[a-zA-Z0-9_-]+$', namespace):
127
- raise ValueError(
128
- f"Invalid namespace '{namespace}': "
129
- "only alphanumeric characters, hyphens, and underscores are allowed"
130
- )
142
+ ValueError: If namespace is empty/None/non-string, or contains
143
+ characters outside [A-Za-z0-9_-] (path-traversal defense).
144
+ """
145
+ # with_namespace is an explicit "switch to this namespace" call, so an
146
+ # empty, None, whitespace-only, or non-string namespace is meaningless
147
+ # and must be rejected rather than silently resolving to the default
148
+ # root (which is what __init__ would do with a falsy namespace). Reject,
149
+ # do not normalize: normalization would mask a caller bug.
150
+ if not isinstance(namespace, str) or not namespace.strip():
151
+ raise ValueError(
152
+ f"Invalid namespace {namespace!r}: "
153
+ "must be a non-empty string"
154
+ )
155
+ if namespace != DEFAULT_NAMESPACE:
156
+ _validate_namespace_charset(namespace)
131
157
  return MemoryStorage(
132
158
  base_path=str(self._root_path),
133
159
  namespace=namespace,
@@ -857,6 +883,75 @@ class MemoryStorage:
857
883
 
858
884
  return True
859
885
 
886
+ def update_pattern_with_merge(self, pattern_id: str, merge_fn) -> bool:
887
+ """Atomically merge into an existing pattern under a single lock.
888
+
889
+ Closes the consolidation lost-update (BUG-MEM C1): the previous flow read
890
+ the pattern (load_pattern) and wrote the merged result (update_pattern) in
891
+ SEPARATE lock acquisitions, so a concurrent increment_pattern_usage() bump
892
+ landing between the read and the write was lost. Here the read of the
893
+ current on-disk record, the caller's merge, and the write all happen
894
+ inside ONE exclusive _file_lock on patterns.json -- the same path
895
+ increment_pattern_usage() and update_pattern() lock -- so they mutually
896
+ exclude and no bump is clobbered.
897
+
898
+ Args:
899
+ pattern_id: Id of the existing pattern to merge into.
900
+ merge_fn: Callable taking the current on-disk pattern dict and
901
+ returning the merged record (dict, or any object exposing
902
+ to_dict()/__dict__). It must preserve the id. The dict it
903
+ receives is a fresh read performed under the lock.
904
+
905
+ Returns:
906
+ True if the pattern was found and the merged record written, False if
907
+ the pattern id was not present (caller should fall back to a create).
908
+ """
909
+ if not pattern_id:
910
+ return False
911
+
912
+ patterns_path = self.base_path / "semantic" / "patterns.json"
913
+
914
+ with self._file_lock(patterns_path, exclusive=True):
915
+ if not patterns_path.exists():
916
+ return False
917
+
918
+ with open(patterns_path, "r", encoding="utf-8") as f:
919
+ try:
920
+ patterns_file = json.load(f)
921
+ except (json.JSONDecodeError, UnicodeDecodeError):
922
+ return False
923
+
924
+ patterns = patterns_file.get("patterns", [])
925
+ target_idx = None
926
+ current = None
927
+ for i, p in enumerate(patterns):
928
+ if isinstance(p, dict) and p.get("id") == pattern_id:
929
+ target_idx = i
930
+ current = p
931
+ break
932
+
933
+ if target_idx is None:
934
+ return False
935
+
936
+ # Caller merges against the fresh, lock-protected current record.
937
+ merged = merge_fn(current)
938
+ if hasattr(merged, "to_dict"):
939
+ merged_data = merged.to_dict()
940
+ elif hasattr(merged, "__dict__"):
941
+ merged_data = merged.__dict__.copy()
942
+ else:
943
+ merged_data = dict(merged)
944
+
945
+ # Never let a merge orphan the record by changing its id.
946
+ merged_data["id"] = pattern_id
947
+ merged_data["updated_at"] = datetime.now(timezone.utc).isoformat()
948
+ patterns_file["patterns"][target_idx] = merged_data
949
+ patterns_file["last_updated"] = datetime.now(timezone.utc).isoformat()
950
+
951
+ self._atomic_write(patterns_path, patterns_file)
952
+
953
+ return True
954
+
860
955
  # -------------------------------------------------------------------------
861
956
  # Skill Storage
862
957
  # -------------------------------------------------------------------------
@@ -1259,9 +1354,20 @@ class MemoryStorage:
1259
1354
  base = min(1.0, base + 0.05 * min(len(errors), 3))
1260
1355
 
1261
1356
  # Access frequency boost (diminishing returns).
1262
- # `or 0` guards against an explicit null access_count crashing the
1263
- # comparison and log1p call below.
1357
+ # `or 0` guards an explicit null access_count; the isinstance/`< 0`
1358
+ # clamp additionally guards a non-numeric (e.g. a stored "5") or negative
1359
+ # value (corrupt or hand-edited record) reaching the `> 0` comparison and
1360
+ # log1p() below. A bare string raises TypeError on `"5" > 0`, and a
1361
+ # negative <= -1 raises a math domain error in log1p; bool is excluded so
1362
+ # a stray True is not treated as a count of 1. All coerce to 0 (no boost)
1363
+ # so importance scoring never crashes the scan.
1264
1364
  access_count = memory.get("access_count") or 0
1365
+ if (
1366
+ not isinstance(access_count, (int, float))
1367
+ or isinstance(access_count, bool)
1368
+ or access_count < 0
1369
+ ):
1370
+ access_count = 0
1265
1371
  if access_count > 0:
1266
1372
  # Log scale boost, caps at about 0.15 for 100+ accesses
1267
1373
  access_boost = 0.05 * math.log1p(access_count)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.95.0",
4
+ "version": "7.97.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "7.95.0",
5
+ "version": "7.97.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",