loki-mode 7.96.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.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.96.0
6
+ # Loki Mode v7.97.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -408,4 +408,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
408
408
 
409
409
  ---
410
410
 
411
- **v7.96.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.97.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.96.0
1
+ 7.97.0
@@ -162,7 +162,14 @@ hook_pre_file_edit() {
162
162
  [[ -z "$file_path" ]] && return 0
163
163
  local snap_base
164
164
  snap_base=$(_migration_snapshot_dir)
165
- _heal_snapshot_save "$snap_base" "$file_path"
165
+ # Enforce the pairing contract: if the snapshot cannot be captured, BLOCK the
166
+ # edit. Proceeding would leave post_file_edit's block_and_rollback path with
167
+ # no snapshot to restore -- a test failure would then leave the broken edit
168
+ # in place (silent revert failure). Fail loud/closed here instead.
169
+ if ! _heal_snapshot_save "$snap_base" "$file_path"; then
170
+ echo "HOOK_BLOCKED: could not capture a pre-edit snapshot for ${file_path}; refusing to edit without a revert path. Check that ${snap_base}/snapshots is writable."
171
+ return 1
172
+ fi
166
173
  return 0
167
174
  }
168
175
 
@@ -418,7 +425,15 @@ print('OK')
418
425
  # Capture a pre-edit snapshot so post_healing_modify can revert ONLY the
419
426
  # healing edit on test failure (not unrelated uncommitted changes, and not
420
427
  # via git checkout which discards everything). Keyed by file path.
421
- _heal_snapshot_save "$heal_dir" "$file_path"
428
+ #
429
+ # Enforce the pairing contract: if the snapshot cannot be captured, BLOCK the
430
+ # edit. Proceeding would leave the edit with no revert path -- a later test
431
+ # failure would then leave the broken edit in place while honestly reporting
432
+ # "no snapshot" (silent revert failure). Fail loud/closed here instead.
433
+ if ! _heal_snapshot_save "$heal_dir" "$file_path"; then
434
+ echo "HOOK_BLOCKED: could not capture a pre-edit snapshot for ${file_path}; refusing to modify it without a revert path. Check that ${heal_dir}/snapshots is writable."
435
+ return 1
436
+ fi
422
437
 
423
438
  return 0
424
439
  }
@@ -438,27 +453,63 @@ _heal_snapshot_path() {
438
453
  # healing edit will CREATE it), write a sentinel marker instead so the revert
439
454
  # path knows to remove the file rather than restore content.
440
455
  #
441
- # Pairing contract: hook_pre_healing_modify (which calls this) MUST run for a
442
- # file before hook_post_healing_modify reverts it. The snapshot is refreshed on
456
+ # Pairing contract (ENFORCED, fail-closed): hook_pre_healing_modify (which calls
457
+ # this) MUST run for a file before hook_post_healing_modify reverts it, AND that
458
+ # pre call MUST leave behind a revertable snapshot. The snapshot is refreshed on
443
459
  # every pre call, so a post without a matching fresh pre could restore a stale
444
460
  # blob. On the success path the snapshot is intentionally left in place; the
445
461
  # next pre overwrites it.
462
+ #
463
+ # Returns 0 ONLY when a revertable snapshot demonstrably exists on disk after
464
+ # this call: exactly one of {content snapshot, ".absent" marker}. Returns 1 on
465
+ # ANY failure (cannot create the snapshot dir, cp/sentinel write failed, the
466
+ # wrong marker survived, or both/neither markers exist). A 1 return MUST block
467
+ # the edit at the caller -- otherwise the edit proceeds with no revert path and a
468
+ # later test failure leaves the broken edit in place (the silent-revert-failure
469
+ # this guard exists to prevent). NEVER return 0 on a failure path.
446
470
  _heal_snapshot_save() {
447
471
  local heal_dir="$1"
448
472
  local file_path="$2"
473
+ # Empty file_path: nothing to snapshot. Callers treat empty file_path as a
474
+ # no-op (return 0 before reaching here in the hooks); not a contract breach.
449
475
  [[ -z "$file_path" ]] && return 0
450
476
  local snap_dir="$heal_dir/snapshots"
451
- mkdir -p "$snap_dir" 2>/dev/null || return 0
477
+ if ! mkdir -p "$snap_dir" 2>/dev/null || [[ ! -d "$snap_dir" ]]; then
478
+ return 1
479
+ fi
452
480
  local snap
453
481
  snap=$(_heal_snapshot_path "$heal_dir" "$file_path")
454
482
  if [[ -f "$file_path" ]]; then
455
- cp "$file_path" "$snap" 2>/dev/null || return 0
456
- rm -f "$snap.absent" 2>/dev/null || true
483
+ # Capture content, then drop any stale absent-marker so EXACTLY the
484
+ # content snapshot survives. Fail closed if either step fails.
485
+ if ! cp "$file_path" "$snap" 2>/dev/null; then
486
+ return 1
487
+ fi
488
+ if ! rm -f "$snap.absent" 2>/dev/null; then
489
+ return 1
490
+ fi
491
+ # Verify exactly the content snapshot exists and the absent-marker is
492
+ # gone. A lingering marker is a contract violation (restore checks the
493
+ # content snapshot first, but the inconsistency must not be tolerated).
494
+ if [[ ! -f "$snap" || -f "$snap.absent" ]]; then
495
+ return 1
496
+ fi
457
497
  else
458
498
  # File does not exist pre-edit: record an "absent" marker, drop any
459
- # stale content snapshot.
460
- rm -f "$snap" 2>/dev/null || true
461
- : > "$snap.absent" 2>/dev/null || true
499
+ # stale content snapshot. CRITICAL: if the stale content snapshot is not
500
+ # removed, restore checks the content snapshot FIRST and would restore
501
+ # content for a file that should have been REMOVED. Fail closed if the
502
+ # stale snapshot cannot be cleared or the marker cannot be written.
503
+ if ! rm -f "$snap" 2>/dev/null; then
504
+ return 1
505
+ fi
506
+ if ! : > "$snap.absent" 2>/dev/null; then
507
+ return 1
508
+ fi
509
+ # Verify exactly the absent-marker exists and no content snapshot does.
510
+ if [[ ! -f "$snap.absent" || -f "$snap" ]]; then
511
+ return 1
512
+ fi
462
513
  fi
463
514
  return 0
464
515
  }
package/autonomy/run.sh CHANGED
@@ -3801,6 +3801,82 @@ effective_session_cap() {
3801
3801
  return 0
3802
3802
  }
3803
3803
 
3804
+ # Auto-flag parity for parallel worktree Claude sessions (wave-5 fix).
3805
+ # The main RARV loop applies adaptive cost/resilience flags to its claude
3806
+ # invocation (run.sh:16007-16030 effort/max-budget/fallback) plus an MCP
3807
+ # bundle, but the parallel worktree spawn shipped a bare invocation that missed
3808
+ # them entirely. This populates the global _LOKI_WT_AUTO_FLAGS array with ONLY
3809
+ # the flags that are safe on the plain (non stream-json) worktree invocation:
3810
+ # --effort adaptive reasoning depth (dev tier)
3811
+ # --max-budget-usd per-call hard backstop
3812
+ # --fallback-model resilience to model overload/unavailability
3813
+ # --mcp-config (+ --strict-mcp-config) the same MCP bundle the Bun
3814
+ # route emits; a SUPERSET of the bash main loop, which does
3815
+ # NOT emit --mcp-config (see ledger run.sh:15990-15996).
3816
+ # Worktree dev streams benefit from the bundled MCP servers.
3817
+ # We deliberately do NOT replicate the stream-json-coupled flags
3818
+ # (--include-hook-events, --include-partial-messages) nor --output-format /
3819
+ # --session-id: this invocation logs free-form text, not parsed stream-json, so
3820
+ # those would be invalid or unwanted here. Each flag is gated on CLI support +
3821
+ # its opt-out env var, matching the main loop. Extracted from spawn_worktree_session
3822
+ # so it is unit-testable (tests/test-worktree-auto-flags.sh).
3823
+ _loki_build_worktree_claude_flags() {
3824
+ _LOKI_WT_AUTO_FLAGS=()
3825
+ # Non-claude providers never receive these flags.
3826
+ if [ "${PROVIDER_NAME:-claude}" != "claude" ]; then
3827
+ return 0
3828
+ fi
3829
+ # Dev-tier model param drives the fallback derivation (worktree streams are
3830
+ # development work). Resolve via the provider helper when available.
3831
+ local _loki_wt_primary=""
3832
+ if type provider_get_tier_param >/dev/null 2>&1; then
3833
+ _loki_wt_primary="$(provider_get_tier_param development 2>/dev/null || true)"
3834
+ fi
3835
+ if [ "${LOKI_AUTO_EFFORT:-on}" != "off" ] \
3836
+ && type loki_effort_for_tier >/dev/null 2>&1 \
3837
+ && type loki_claude_flag_supported >/dev/null 2>&1 \
3838
+ && loki_claude_flag_supported "--effort"; then
3839
+ local _loki_wt_effort
3840
+ _loki_wt_effort="$(loki_effort_for_tier development "${DETECTED_COMPLEXITY:-${LOKI_COMPLEXITY:-standard}}")"
3841
+ [ -n "$_loki_wt_effort" ] && _LOKI_WT_AUTO_FLAGS+=("--effort" "$_loki_wt_effort")
3842
+ fi
3843
+ if [ "${LOKI_AUTO_BUDGET:-on}" != "off" ] \
3844
+ && type loki_remaining_budget >/dev/null 2>&1 \
3845
+ && type loki_claude_flag_supported >/dev/null 2>&1 \
3846
+ && loki_claude_flag_supported "--max-budget-usd"; then
3847
+ local _loki_wt_rem
3848
+ _loki_wt_rem="$(loki_remaining_budget)"
3849
+ [ -n "$_loki_wt_rem" ] && _LOKI_WT_AUTO_FLAGS+=("--max-budget-usd" "$_loki_wt_rem")
3850
+ fi
3851
+ if [ "${LOKI_AUTO_FALLBACK:-on}" != "off" ] \
3852
+ && [ -n "$_loki_wt_primary" ] \
3853
+ && type loki_fallback_for_primary >/dev/null 2>&1 \
3854
+ && type loki_claude_flag_supported >/dev/null 2>&1 \
3855
+ && loki_claude_flag_supported "--fallback-model"; then
3856
+ local _loki_wt_fb
3857
+ _loki_wt_fb="$(loki_fallback_for_primary "$_loki_wt_primary")"
3858
+ [ -n "$_loki_wt_fb" ] && _LOKI_WT_AUTO_FLAGS+=("--fallback-model" "$_loki_wt_fb")
3859
+ fi
3860
+ if type loki_mcp_config_argv >/dev/null 2>&1 \
3861
+ && type loki_claude_flag_supported >/dev/null 2>&1 \
3862
+ && loki_claude_flag_supported "--mcp-config"; then
3863
+ local _loki_wt_mcp
3864
+ if _loki_wt_mcp="$(loki_mcp_config_argv)" && [ -n "$_loki_wt_mcp" ]; then
3865
+ _LOKI_WT_AUTO_FLAGS+=("--mcp-config")
3866
+ local _loki_wt_mcp_path
3867
+ for _loki_wt_mcp_path in $_loki_wt_mcp; do
3868
+ _LOKI_WT_AUTO_FLAGS+=("$_loki_wt_mcp_path")
3869
+ done
3870
+ # --strict-mcp-config only alongside a real bundle, never bare.
3871
+ if [ "${LOKI_STRICT_MCP:-1}" != "0" ] \
3872
+ && loki_claude_flag_supported "--strict-mcp-config"; then
3873
+ _LOKI_WT_AUTO_FLAGS+=("--strict-mcp-config")
3874
+ fi
3875
+ fi
3876
+ fi
3877
+ return 0
3878
+ }
3879
+
3804
3880
  # Spawn a Claude session in a worktree
3805
3881
  spawn_worktree_session() {
3806
3882
  local stream_name="$1"
@@ -3843,6 +3919,11 @@ spawn_worktree_session() {
3843
3919
 
3844
3920
  log_step "Spawning ${PROVIDER_DISPLAY_NAME:-Claude} session: $stream_name"
3845
3921
 
3922
+ # Build the worktree auto-flag set (effort/max-budget/fallback/mcp-config)
3923
+ # BEFORE the ( subshell so it is inherited (a `bash -c` would not). Populates
3924
+ # the global _LOKI_WT_AUTO_FLAGS array. See _loki_build_worktree_claude_flags.
3925
+ _loki_build_worktree_claude_flags
3926
+
3846
3927
  (
3847
3928
  cd "$worktree_path" || exit 1
3848
3929
  _wt_exit=0
@@ -3858,12 +3939,17 @@ spawn_worktree_session() {
3858
3939
  if type loki_caveman_activate_env >/dev/null 2>&1; then
3859
3940
  _loki_wt_cm="$(loki_caveman_activate_env)"
3860
3941
  fi
3942
+ # Expand the auto-flag array with the bash-3.2 empty-array guard
3943
+ # (${arr[@]+...}) so a bare "${arr[@]}" under set -u does not
3944
+ # abort with "unbound variable" when no flags were collected.
3861
3945
  if [ -n "$_loki_wt_cm" ]; then
3862
3946
  CAVEMAN_DEFAULT_MODE="$_loki_wt_cm" claude --dangerously-skip-permissions \
3947
+ "${_LOKI_WT_AUTO_FLAGS[@]+"${_LOKI_WT_AUTO_FLAGS[@]}"}" \
3863
3948
  -p "Loki Mode: $task_prompt. Read .loki/CONTINUITY.md for context." \
3864
3949
  >> "$log_file" 2>&1 || _wt_exit=$?
3865
3950
  else
3866
3951
  claude --dangerously-skip-permissions \
3952
+ "${_LOKI_WT_AUTO_FLAGS[@]+"${_LOKI_WT_AUTO_FLAGS[@]}"}" \
3867
3953
  -p "Loki Mode: $task_prompt. Read .loki/CONTINUITY.md for context." \
3868
3954
  >> "$log_file" 2>&1 || _wt_exit=$?
3869
3955
  fi
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.96.0"
10
+ __version__ = "7.97.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.96.0
5
+ **Version:** v7.97.0
6
6
 
7
7
  ---
8
8
 
package/events/bus.py CHANGED
@@ -130,6 +130,16 @@ class LokiEvent:
130
130
  # dedup (_processed_ids + pending-file glob) and re-importing every flat
131
131
  # line on each call. Derive a DETERMINISTIC id from the record content
132
132
  # so repeated imports of the same line are idempotent.
133
+ #
134
+ # Width: the digest source is user-controllable (timestamp + type +
135
+ # payload). An 8-hex-char (32-bit) truncation collides at the birthday
136
+ # bound around ~77k distinct events (sqrt(2^32)); a collision makes two
137
+ # DISTINCT events share an id, so dedup (_processed_ids + pending glob)
138
+ # silently DROPS the second event. Widen to 16 hex chars (64 bits) so
139
+ # the collision bound moves to ~4 billion distinct events -- well past
140
+ # any realistic events.jsonl. This deterministic-derivation path is
141
+ # Python-only (bus.ts has no jsonl import and mints a random id when one
142
+ # is absent), so widening here needs no cross-language parity change.
133
143
  event_id = data.get('id', '')
134
144
  if not event_id:
135
145
  digest_src = '%s|%s|%s' % (
@@ -137,7 +147,7 @@ class LokiEvent:
137
147
  raw_type,
138
148
  json.dumps(payload, sort_keys=True),
139
149
  )
140
- event_id = hashlib.sha1(digest_src.encode('utf-8')).hexdigest()[:8]
150
+ event_id = hashlib.sha1(digest_src.encode('utf-8')).hexdigest()[:16]
141
151
 
142
152
  return cls(
143
153
  id=event_id,
package/events/emit.sh CHANGED
@@ -119,16 +119,27 @@ fi
119
119
 
120
120
  # JSON escape helper: handles \, ", and control characters including newlines
121
121
  #
122
- # The sed pass escapes the named short forms (\\ \" \t \r \b \f); the first awk
123
- # pass collapses embedded newlines to \n. Any OTHER C0 control byte
124
- # (0x01-0x07, 0x0B, 0x0E-0x1F) is invalid raw inside a JSON string and is
125
- # escaped as \uXXXX by the final awk pass -- otherwise json.loads / JSON.parse
126
- # reject the line and consumers (dashboard _read_events, learning aggregator)
127
- # silently drop it. The named short forms are already two-char ASCII by the
128
- # time the final pass runs, so they are never re-escaped.
122
+ # The sed pass escapes backslash, double-quote, tab and carriage-return to
123
+ # their named short forms (\\ \" \t \r); the first awk pass collapses embedded
124
+ # newlines to \n. Every REMAINING C0 control byte (0x01-0x08, 0x0B-0x0C,
125
+ # 0x0E-0x1F -- which includes backspace 0x08 and form-feed 0x0C) is invalid raw
126
+ # inside a JSON string and is escaped as \uXXXX by the final awk pass (its map
127
+ # covers all of i=1..31). Without that escaping json.loads / JSON.parse reject
128
+ # the line and consumers (dashboard _read_events, learning aggregator) silently
129
+ # drop it. The named short forms emitted by sed are already two-char ASCII by
130
+ # the time the final pass runs, so they are never re-escaped.
131
+ #
132
+ # NOTE: backspace/form-feed are deliberately NOT escaped in the sed pass.
133
+ # Doing so requires embedding the raw control bytes in the sed pattern, which
134
+ # editors silently strip -- that left the two empty-pattern sed no-ops this
135
+ # helper previously carried. An empty sed regex reuses the LAST applied regex,
136
+ # so those entries were dead AND a latent corruption hazard. The final awk
137
+ # pass already escapes every byte in i=1..31 (backspace and form-feed included)
138
+ # to the six-char uXXXX unicode escape, producing valid JSON, so the
139
+ # named-short-form variants are unnecessary.
129
140
  json_escape() {
130
141
  printf '%s' "$1" \
131
- | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g; s//\\b/g; s//\\f/g' \
142
+ | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' \
132
143
  | awk '{if(NR>1) printf "\\n"; printf "%s", $0}' \
133
144
  | awk 'BEGIN{for(i=1;i<=31;i++)m[sprintf("%c",i)]=sprintf("\\u%04x",i)}
134
145
  {s=""; n=length($0);
@@ -163,9 +174,23 @@ EVENT=$(cat <<EOF
163
174
  EOF
164
175
  )
165
176
 
166
- # Write event file
177
+ # Write event file atomically.
178
+ #
179
+ # A bare `echo "$EVENT" > "$EVENT_FILE"` is NON-atomic: a reader (bus.py
180
+ # get_pending_events / from_dict, dashboard) that globs `*.json` mid-write sees
181
+ # a truncated/partial file and either fails json.loads (event silently dropped)
182
+ # or, worse, reads a half-written record. Write to a sibling temp file in the
183
+ # SAME directory (so rename(2) is an atomic same-filesystem operation) and then
184
+ # rename into place; a `.tmp` suffix keeps the in-progress file out of the
185
+ # `*.json` glob readers use. Clean up the temp on any failure so partials never
186
+ # linger.
167
187
  EVENT_FILE="$EVENTS_DIR/${TIMESTAMP//:/-}_$EVENT_ID.json"
168
- echo "$EVENT" > "$EVENT_FILE"
188
+ EVENT_TMP="$EVENT_FILE.tmp"
189
+ if printf '%s\n' "$EVENT" > "$EVENT_TMP" && mv -f "$EVENT_TMP" "$EVENT_FILE"; then
190
+ :
191
+ else
192
+ rm -f "$EVENT_TMP" 2>/dev/null || true
193
+ fi
169
194
 
170
195
  # Rotate events.jsonl if it exceeds 50MB (keep 1 backup)
171
196
  EVENTS_LOG="$LOKI_DIR/events.jsonl"
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var z8=Object.defineProperty;var X8=($)=>$;function K8($,Q){this[$]=X8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)z8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:K8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var y1={};b(y1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as t,dirname as i$}from"path";import{fileURLToPath as q8}from"url";import{existsSync as E$}from"fs";import{homedir as J8}from"os";function V8(){let $=h1;for(let Q=0;Q<6;Q++){if(E$(t($,"VERSION"))&&E$(t($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return t(h1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(E$(t(Q,"VERSION"))&&E$(t(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function P(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(J8(),".loki")}var h1,h;var C=L(()=>{h1=i$(q8(import.meta.url));h=V8()});import{readFileSync as W8}from"fs";import{resolve as U8,dirname as H8}from"path";import{fileURLToPath as G8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.96.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=H8(G8(import.meta.url)),Z=e$(Q);Q$=W8(U8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var u1={};b(u1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>f1,commandVersion:()=>N8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>m1});async function f1($,Q=m1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([f1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function x8($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new Q1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=E8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function E8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function N8($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var m1=16777216,Q1;var d=L(()=>{Q1=class Q1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function r($){return S8?"":$}var S8,T,S,_,lZ,I,k,y,J;var c=L(()=>{S8=(process.env.NO_COLOR??"").length>0;T=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),lZ=r("\x1B[0;34m"),I=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as u8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(u8($))return A$=$,$;let Q=await f("python3.12");if(Q)return A$=Q,Q;let Z=await f("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var A$;var V$=L(()=>{d()});var q0={};b(q0,{runStatus:()=>W3});import{existsSync as v,readFileSync as U$,readdirSync as i1,statSync as e1}from"fs";import{resolve as D,basename as $3}from"path";import{homedir as Q3}from"os";function $0($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function Q0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*D$/Q);if(X>D$)X=D$;let q=D$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),V=$0($),U=$0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function z3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
2
+ var z8=Object.defineProperty;var X8=($)=>$;function K8($,Q){this[$]=X8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)z8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:K8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var y1={};b(y1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as t,dirname as i$}from"path";import{fileURLToPath as q8}from"url";import{existsSync as E$}from"fs";import{homedir as J8}from"os";function V8(){let $=h1;for(let Q=0;Q<6;Q++){if(E$(t($,"VERSION"))&&E$(t($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return t(h1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(E$(t(Q,"VERSION"))&&E$(t(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return t($,"..","..","..")}function P(){return process.env.LOKI_DIR??t(process.cwd(),".loki")}function T$(){return t(J8(),".loki")}var h1,h;var C=L(()=>{h1=i$(q8(import.meta.url));h=V8()});import{readFileSync as W8}from"fs";import{resolve as U8,dirname as H8}from"path";import{fileURLToPath as G8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.97.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=H8(G8(import.meta.url)),Z=e$(Q);Q$=W8(U8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var u1={};b(u1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>f1,commandVersion:()=>N8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>m1});async function f1($,Q=m1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([f1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function x8($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new Q1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=E8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function E8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function N8($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var m1=16777216,Q1;var d=L(()=>{Q1=class Q1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function r($){return S8?"":$}var S8,T,S,_,lZ,I,k,y,J;var c=L(()=>{S8=(process.env.NO_COLOR??"").length>0;T=r("\x1B[0;31m"),S=r("\x1B[0;32m"),_=r("\x1B[1;33m"),lZ=r("\x1B[0;34m"),I=r("\x1B[0;36m"),k=r("\x1B[1m"),y=r("\x1B[2m"),J=r("\x1B[0m")});import{existsSync as u8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(u8($))return A$=$,$;let Q=await f("python3.12");if(Q)return A$=Q,Q;let Z=await f("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var A$;var V$=L(()=>{d()});var q0={};b(q0,{runStatus:()=>W3});import{existsSync as v,readFileSync as U$,readdirSync as i1,statSync as e1}from"fs";import{resolve as D,basename as $3}from"path";import{homedir as Q3}from"os";function $0($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function Q0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*D$/Q);if(X>D$)X=D$;let q=D$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),V=$0($),U=$0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function z3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -802,4 +802,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
802
802
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
803
803
  `),process.stderr.write(Z8),2}}r1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var FZ=await jZ(Bun.argv.slice(2));process.exit(FZ);
804
804
 
805
- //# debugId=E60A742D72C38FD764756E2164756E21
805
+ //# debugId=ABBB64E42A394DE264756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.96.0'
60
+ __version__ = '7.97.0'
package/memory/engine.py CHANGED
@@ -1034,6 +1034,39 @@ class MemoryEngine:
1034
1034
 
1035
1035
  return None
1036
1036
 
1037
+ @staticmethod
1038
+ def _parse_optional_datetime(
1039
+ raw: Any, *, record_id: str = "<unknown>", field: str = "last_accessed"
1040
+ ) -> Optional[datetime]:
1041
+ """Tolerantly parse an optional ISO datetime field off a stored record.
1042
+
1043
+ A corrupt/non-ISO value on ONE record must not raise out of the
1044
+ _dict_to_* converters and crash the whole batch list-comp in
1045
+ get_recent_episodes / find_patterns / list_skills (which would drop EVERY
1046
+ item in the scan, not just the bad one) on the RARV retrieval hot path.
1047
+ An unparseable value falls back to None (treated as never-accessed) so
1048
+ the record is still returned and retrievable.
1049
+
1050
+ Returns a tz-aware datetime (UTC assumed when naive), or None when the
1051
+ value is missing/empty/unparseable.
1052
+ """
1053
+ if not raw:
1054
+ return None
1055
+ if isinstance(raw, datetime):
1056
+ return raw.replace(tzinfo=timezone.utc) if raw.tzinfo is None else raw
1057
+ if isinstance(raw, str):
1058
+ value = raw[:-1] if raw.endswith("Z") else raw
1059
+ try:
1060
+ parsed = datetime.fromisoformat(value)
1061
+ except ValueError:
1062
+ logger.warning(
1063
+ "Record %s has unparseable %s %r; treating as never-accessed",
1064
+ record_id, field, raw,
1065
+ )
1066
+ return None
1067
+ return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed
1068
+ return None
1069
+
1037
1070
  def _dict_to_episode(self, data: Dict[str, Any]) -> EpisodeTrace:
1038
1071
  """Convert dictionary to EpisodeTrace."""
1039
1072
  # Parse timestamp string to datetime
@@ -1081,20 +1114,13 @@ class MemoryEngine:
1081
1114
  for e in errors_raw
1082
1115
  ]
1083
1116
 
1084
- # Parse last_accessed datetime
1085
- last_accessed = None
1086
- last_accessed_raw = data.get("last_accessed")
1087
- if last_accessed_raw:
1088
- if isinstance(last_accessed_raw, str):
1089
- if last_accessed_raw.endswith("Z"):
1090
- last_accessed_raw = last_accessed_raw[:-1]
1091
- last_accessed = datetime.fromisoformat(last_accessed_raw)
1092
- if last_accessed.tzinfo is None:
1093
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1094
- elif isinstance(last_accessed_raw, datetime):
1095
- last_accessed = last_accessed_raw
1096
- if last_accessed.tzinfo is None:
1097
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1117
+ # Parse last_accessed datetime (tolerant: a corrupt value falls back to
1118
+ # None so a single bad episode never crashes the retrieval batch).
1119
+ last_accessed = self._parse_optional_datetime(
1120
+ data.get("last_accessed"),
1121
+ record_id=data.get("id", "<unknown>"),
1122
+ field="last_accessed",
1123
+ )
1098
1124
 
1099
1125
  return EpisodeTrace(
1100
1126
  id=data.get("id", ""),
@@ -1119,21 +1145,14 @@ class MemoryEngine:
1119
1145
 
1120
1146
  def _dict_to_pattern(self, data: Dict[str, Any]) -> SemanticPattern:
1121
1147
  """Convert dictionary to SemanticPattern."""
1122
- # Parse last_used string to datetime or None
1123
- last_used = None
1124
- last_used_raw = data.get("last_used")
1125
- if last_used_raw:
1126
- if isinstance(last_used_raw, str):
1127
- # Handle ISO format with Z suffix
1128
- if last_used_raw.endswith("Z"):
1129
- last_used_raw = last_used_raw[:-1]
1130
- last_used = datetime.fromisoformat(last_used_raw)
1131
- if last_used.tzinfo is None:
1132
- last_used = last_used.replace(tzinfo=timezone.utc)
1133
- elif isinstance(last_used_raw, datetime):
1134
- last_used = last_used_raw
1135
- if last_used.tzinfo is None:
1136
- last_used = last_used.replace(tzinfo=timezone.utc)
1148
+ # Parse last_used string to datetime or None (tolerant: a corrupt value
1149
+ # on one pattern must not crash the find_patterns retrieval batch; it
1150
+ # shares the same hot path and crash mechanism as last_accessed below).
1151
+ last_used = self._parse_optional_datetime(
1152
+ data.get("last_used"),
1153
+ record_id=data.get("id", "<unknown>"),
1154
+ field="last_used",
1155
+ )
1137
1156
 
1138
1157
  # Convert links dicts to Link objects
1139
1158
  links_raw = data.get("links", [])
@@ -1142,20 +1161,12 @@ class MemoryEngine:
1142
1161
  for link in links_raw
1143
1162
  ]
1144
1163
 
1145
- # Parse last_accessed datetime
1146
- last_accessed = None
1147
- last_accessed_raw = data.get("last_accessed")
1148
- if last_accessed_raw:
1149
- if isinstance(last_accessed_raw, str):
1150
- if last_accessed_raw.endswith("Z"):
1151
- last_accessed_raw = last_accessed_raw[:-1]
1152
- last_accessed = datetime.fromisoformat(last_accessed_raw)
1153
- if last_accessed.tzinfo is None:
1154
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1155
- elif isinstance(last_accessed_raw, datetime):
1156
- last_accessed = last_accessed_raw
1157
- if last_accessed.tzinfo is None:
1158
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1164
+ # Parse last_accessed datetime (tolerant: see _dict_to_episode).
1165
+ last_accessed = self._parse_optional_datetime(
1166
+ data.get("last_accessed"),
1167
+ record_id=data.get("id", "<unknown>"),
1168
+ field="last_accessed",
1169
+ )
1159
1170
 
1160
1171
  return SemanticPattern(
1161
1172
  id=data.get("id", ""),
@@ -1182,20 +1193,12 @@ class MemoryEngine:
1182
1193
  for e in raw_errors
1183
1194
  ]
1184
1195
 
1185
- # Parse last_accessed datetime
1186
- last_accessed = None
1187
- last_accessed_raw = data.get("last_accessed")
1188
- if last_accessed_raw:
1189
- if isinstance(last_accessed_raw, str):
1190
- if last_accessed_raw.endswith("Z"):
1191
- last_accessed_raw = last_accessed_raw[:-1]
1192
- last_accessed = datetime.fromisoformat(last_accessed_raw)
1193
- if last_accessed.tzinfo is None:
1194
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1195
- elif isinstance(last_accessed_raw, datetime):
1196
- last_accessed = last_accessed_raw
1197
- if last_accessed.tzinfo is None:
1198
- last_accessed = last_accessed.replace(tzinfo=timezone.utc)
1196
+ # Parse last_accessed datetime (tolerant: see _dict_to_episode).
1197
+ last_accessed = self._parse_optional_datetime(
1198
+ data.get("last_accessed"),
1199
+ record_id=data.get("id", "<unknown>"),
1200
+ field="last_accessed",
1201
+ )
1199
1202
 
1200
1203
  return ProceduralSkill(
1201
1204
  id=data.get("id", ""),
@@ -9,10 +9,66 @@ for injection into the RARV prompt cycle.
9
9
 
10
10
  import argparse
11
11
  import json
12
+ import re
12
13
  import sys
13
14
  from pathlib import Path
14
15
 
15
16
 
17
+ # Per-field hard cap so a single oversized/garbage memory entry cannot dominate
18
+ # the injected context or push real signal out of the token budget.
19
+ _MAX_FIELD_CHARS = 600
20
+
21
+ # Control characters (excluding ordinary whitespace handled separately) that
22
+ # must never reach the prompt: they can corrupt rendering or smuggle hidden
23
+ # directives. Covers C0 controls and DEL.
24
+ _CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
25
+
26
+
27
+ def _sanitize_field(value, max_chars=_MAX_FIELD_CHARS):
28
+ """Neutralize a single field from stored memory before it enters a prompt.
29
+
30
+ Memory entries are untrusted input: a malicious or garbage pattern could
31
+ contain prompt-injection payloads (fake instructions, fabricated section
32
+ headers, code-fence breakers, or directives that try to override the RARV
33
+ prompt). This collapses each field to a single safe inline line so it can
34
+ only ever read as data, never as structure or instructions.
35
+
36
+ Steps:
37
+ - Coerce non-strings to str (a dict/list value would otherwise break
38
+ formatting or interpolate unexpected structure).
39
+ - Drop control characters (keeps the visible text intact).
40
+ - Collapse ALL newlines/tabs/carriage-returns to single spaces so the
41
+ field cannot add new lines, fake "### " section headers, close the
42
+ knowledge block, or open/close a markdown code fence.
43
+ - Defang leading structural markdown markers (#, >, -, *, backticks) so the
44
+ field cannot pose as a heading/list/fence even after collapsing.
45
+ - Truncate to a bounded length.
46
+ """
47
+ if value is None:
48
+ return ''
49
+ if not isinstance(value, str):
50
+ value = str(value)
51
+ # Remove control characters first.
52
+ value = _CONTROL_CHARS_RE.sub('', value)
53
+ # Collapse any vertical whitespace and tabs into single spaces. This is the
54
+ # core injection defense: without newlines the field cannot introduce new
55
+ # markdown structure or standalone instruction lines.
56
+ value = re.sub(r'[\r\n\t]+', ' ', value)
57
+ # Collapse runs of spaces left behind.
58
+ value = re.sub(r' {2,}', ' ', value).strip()
59
+ # Defang leading structural markdown so the field cannot masquerade as a
60
+ # heading, blockquote, list item, or code fence.
61
+ value = re.sub(r'^[#>\-*`]+\s*', '', value)
62
+ # Defang markdown header markers anywhere in the (now single-line) value so
63
+ # a collapsed payload cannot reintroduce a "### " section marker inline.
64
+ # Backticks are neutralized too so a field cannot open/close a code fence.
65
+ value = re.sub(r'#{1,6}\s', '', value)
66
+ value = value.replace('`', '')
67
+ if len(value) > max_chars:
68
+ value = value[:max_chars].rstrip() + '...'
69
+ return value
70
+
71
+
16
72
  def build_rag_context(query, max_tokens=2000, knowledge_dir=None):
17
73
  """Build RAG context from the knowledge graph.
18
74
 
@@ -39,11 +95,20 @@ def build_rag_context(query, max_tokens=2000, knowledge_dir=None):
39
95
  total_chars = 0
40
96
 
41
97
  for p in patterns:
42
- # Support both 'name'/'pattern' and 'description' fields
43
- name = p.get('name', p.get('pattern', 'Unknown Pattern'))
44
- desc = p.get('description', p.get('correct_approach', ''))
45
- category = p.get('category', '')
46
- source = p.get('_source_project', '')
98
+ # Support both 'name'/'pattern' and 'description' fields.
99
+ # Every field is sanitized: memory entries are untrusted input and must
100
+ # not be able to inject instructions or break the prompt structure.
101
+ name = _sanitize_field(p.get('name', p.get('pattern', 'Unknown Pattern')))
102
+ desc = _sanitize_field(p.get('description', p.get('correct_approach', '')))
103
+ category = _sanitize_field(p.get('category', ''))
104
+ source_raw = p.get('_source_project', '')
105
+ # Path().name strips any directory traversal; sanitize the basename too.
106
+ source = _sanitize_field(Path(str(source_raw)).name) if source_raw else ''
107
+
108
+ # name may be empty after sanitization (e.g. a field that was only
109
+ # markdown markers); fall back so the heading is never blank.
110
+ if not name:
111
+ name = 'Unknown Pattern'
47
112
 
48
113
  section = '### ' + name
49
114
  if category:
@@ -52,7 +117,7 @@ def build_rag_context(query, max_tokens=2000, knowledge_dir=None):
52
117
  if desc:
53
118
  section += desc + '\n'
54
119
  if source:
55
- section += '_Source: ' + Path(source).name + '_\n'
120
+ section += '_Source: ' + source + '_\n'
56
121
 
57
122
  if total_chars + len(section) > max_chars:
58
123
  break
@@ -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,
@@ -1328,9 +1354,20 @@ class MemoryStorage:
1328
1354
  base = min(1.0, base + 0.05 * min(len(errors), 3))
1329
1355
 
1330
1356
  # Access frequency boost (diminishing returns).
1331
- # `or 0` guards against an explicit null access_count crashing the
1332
- # 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.
1333
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
1334
1371
  if access_count > 0:
1335
1372
  # Log scale boost, caps at about 0.15 for 100+ accesses
1336
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.96.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.96.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",