@memnexus-ai/cli 1.7.233 → 1.7.235

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 +1 @@
1
- {"version":3,"file":"privacy-filter.d.ts","sourceRoot":"","sources":["../../../src/lib/commit-context/privacy-filter.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AA6BH;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAU7C"}
1
+ {"version":3,"file":"privacy-filter.d.ts","sourceRoot":"","sources":["../../../src/lib/commit-context/privacy-filter.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAmEH;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAU7C"}
@@ -22,6 +22,43 @@ const SECRET_PATTERNS = [
22
22
  /ghp_[a-zA-Z0-9]{36}/g,
23
23
  // GitHub OAuth / user / service tokens
24
24
  /gh[ous]_[a-zA-Z0-9]{36}/g,
25
+ // MemNexus API keys. This codebase mints TWO shapes and both authenticate,
26
+ // because core-api/src/routes/internal-auth.routes.ts validates only the
27
+ // `cmk_live_` prefix plus exactly one dot:
28
+ // 1. Application path — core-api/src/repositories/apikey.repository.ts:69
29
+ // `cmk_live_<16 hex>.<24 alphanumeric>`
30
+ // 2. Shell/seeding path — database/scripts/generate-api-key-hash.sh:64,
31
+ // database/scripts/seed-system-data.sh:66, core-api/scripts/seed-system-data.sh:74
32
+ // `cmk_live_${ENVIRONMENT}_<16 hex>.<secret>`, ENVIRONMENT in {dev,staging,prod}.
33
+ // This is the shape that seeds production, so missing it is the worse miss.
34
+ // The optional `[a-z]+_` group covers the environment segment; `{8,}` would not,
35
+ // since dev/prod/staging are all shorter than 8 and `_` is not in `[a-zA-Z0-9]`.
36
+ //
37
+ // The keyId is matched as exactly 16 hex characters, case-insensitive. Both
38
+ // generators emit lowercase today, but the class accepts either case so that a
39
+ // generator swapped for one emitting uppercase (`toUpperCase()`, a different
40
+ // uuid implementation) does not silently drop coverage. The environment segment
41
+ // likewise accepts any case. This costs nothing: measured against the full
42
+ // fixture set, widening both classes breaks zero negatives.
43
+ //
44
+ // Anchoring the keyId to 16 hex is deliberate and is what keeps the pattern off
45
+ // prose. `[a-zA-Z0-9]{8,}` would additionally swallow the documented placeholder
46
+ // at api-gateway/.env.example:63 (`cmk_live_dev_placeholder....`) and dotted
47
+ // identifiers such as `cmk_live_handler.processRequest_helper`.
48
+ //
49
+ // The 16-hex shape is a CONTRACT WITH THE GENERATORS, not a guess. It is pinned
50
+ // by an executable coupling test in core-api/tests/unit/utils/privacy-filter.test.ts
51
+ // that builds a keyId through the real generator expressions and asserts this
52
+ // filter redacts it. If a generator changes shape, that test fails loudly rather
53
+ // than this comment quietly becoming false.
54
+ //
55
+ // The dot-plus-body requirement matches a *token*, not a *mention*: prose naming
56
+ // the scheme ("keys look like cmk_live_<id>.<secret>") is left byte-identical,
57
+ // because `<` is outside the character classes.
58
+ //
59
+ // NOT covered: truncated fragments. A partial key with no dot, or a short body,
60
+ // passes through unredacted. See issue #5382.
61
+ /cmk_(?:live|test)_(?:[a-zA-Z]+_)?[0-9a-fA-F]{16}\.[a-zA-Z0-9_-]{8,}/g,
25
62
  // Bearer tokens
26
63
  /Bearer\s+[a-zA-Z0-9._-]{20,}/g,
27
64
  // Generic key=value secrets (password, secret, token, key followed by = or :)
@@ -1 +1 @@
1
- {"version":3,"file":"privacy-filter.js","sourceRoot":"","sources":["../../../src/lib/commit-context/privacy-filter.ts"],"names":[],"mappings":";AAAA;;;;;;GAMG;;AAmCH,4BAUC;AA3CD,MAAM,QAAQ,GAAG,YAAY,CAAC;AAE9B;;;GAGG;AACH,MAAM,eAAe,GAAa;IAChC,wDAAwD;IACxD,8BAA8B;IAE9B,8BAA8B;IAC9B,sBAAsB;IAEtB,gCAAgC;IAChC,sBAAsB;IAEtB,uCAAuC;IACvC,0BAA0B;IAE1B,gBAAgB;IAChB,+BAA+B;IAE/B,8EAA8E;IAC9E,iEAAiE;IACjE,8DAA8D;CAC/D,CAAC;AAEF;;;;;GAKG;AACH,SAAgB,QAAQ,CAAC,IAAY;IACnC,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,IAAI,MAAM,GAAG,IAAI,CAAC;IAClB,KAAK,MAAM,OAAO,IAAI,eAAe,EAAE,CAAC;QACtC,qCAAqC;QACrC,OAAO,CAAC,SAAS,GAAG,CAAC,CAAC;QACtB,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC7C,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
1
+ {"version":3,"file":"privacy-filter.js","sourceRoot":"","sources":["../../../src/lib/commit-context/privacy-filter.ts"],"names":[],"mappings":";AAAA;;;;;;GAMG;;AAyEH,4BAUC;AAjFD,MAAM,QAAQ,GAAG,YAAY,CAAC;AAE9B;;;GAGG;AACH,MAAM,eAAe,GAAa;IAChC,wDAAwD;IACxD,8BAA8B;IAE9B,8BAA8B;IAC9B,sBAAsB;IAEtB,gCAAgC;IAChC,sBAAsB;IAEtB,uCAAuC;IACvC,0BAA0B;IAE1B,2EAA2E;IAC3E,yEAAyE;IACzE,2CAA2C;IAC3C,4EAA4E;IAC5E,6CAA6C;IAC7C,0EAA0E;IAC1E,wFAAwF;IACxF,uFAAuF;IACvF,iFAAiF;IACjF,iFAAiF;IACjF,iFAAiF;IACjF,EAAE;IACF,4EAA4E;IAC5E,+EAA+E;IAC/E,6EAA6E;IAC7E,gFAAgF;IAChF,2EAA2E;IAC3E,4DAA4D;IAC5D,EAAE;IACF,gFAAgF;IAChF,iFAAiF;IACjF,6EAA6E;IAC7E,gEAAgE;IAChE,EAAE;IACF,gFAAgF;IAChF,qFAAqF;IACrF,8EAA8E;IAC9E,iFAAiF;IACjF,4CAA4C;IAC5C,EAAE;IACF,iFAAiF;IACjF,+EAA+E;IAC/E,gDAAgD;IAChD,EAAE;IACF,gFAAgF;IAChF,8CAA8C;IAC9C,sEAAsE;IAEtE,gBAAgB;IAChB,+BAA+B;IAE/B,8EAA8E;IAC9E,iEAAiE;IACjE,8DAA8D;CAC/D,CAAC;AAEF;;;;;GAKG;AACH,SAAgB,QAAQ,CAAC,IAAY;IACnC,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,IAAI,MAAM,GAAG,IAAI,CAAC;IAClB,KAAK,MAAM,OAAO,IAAI,eAAe,EAAE,CAAC;QACtC,qCAAqC;QACrC,OAAO,CAAC,SAAS,GAAG,CAAC,CAAC;QACtB,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC7C,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -201,6 +201,263 @@ $(echo "$RECENT_RESULTS" | head -c 1500)"
201
201
  # Truncate total content to ~5000 chars
202
202
  CONTENT=$(echo "$CONTENT" | head -c 5000)
203
203
 
204
+ # ── 6b. Resolve project scope (codeContext) ──────────────────────────────
205
+ # Auto-captured memories carried no scope at all, so they leaked into every
206
+ # team's scoped searches and polluted build_context (#5158, #4957). PR #3281
207
+ # fixed that for the commit path only; this is the same resolution for the
208
+ # capture hooks, using the same sources and the same precedence.
209
+ #
210
+ # Mirrors resolveCommitCodeContext() in cli/src/lib/commit-context/code-context.ts:
211
+ # 1. MX_CODE_CONTEXT env var — operator-controlled, higher trust. It wins over
212
+ # the file (CWE-345): a checked-in file from an untrusted clone must not
213
+ # override the scope the operator set explicitly.
214
+ # 2. <projectRoot>/.mx-code-context.json — repo-controlled, lower trust.
215
+ # 3. Neither present or neither usable -> NO scope. The memory is created
216
+ # unlabeled, exactly as it was before this block existed. No default is
217
+ # invented: a guessed label is invisible to the scope that should see it
218
+ # and visible to one that should not, which is worse than no label.
219
+ #
220
+ # Every command in this block must end up returning 0. The EXIT/ERR trap set in
221
+ # section 4 exits the hook, so a non-zero status here would discard the
222
+ # pre-compaction context over a missing config file — a far worse regression
223
+ # than the unlabeled memories this fixes. The absent-config path is the common
224
+ # one today.
225
+ CODE_CONTEXT_MAX_BYTES=65536
226
+
227
+ # Walk up for the git project root, mirroring findProjectRoot() in
228
+ # cli/src/lib/commit-context/local-cache.ts. `-e`, not `-d`: inside a git
229
+ # worktree `.git` is a FILE, and the worktree root — not the main repo — is the
230
+ # project root (the TS uses fs.existsSync, which makes no such distinction).
231
+ find_project_root() { # <start-dir> -> project root on stdout, or nothing
232
+ local dir
233
+ dir=$(cd "$1" 2>/dev/null && pwd -P) || return 0
234
+ while [[ -n "$dir" && "$dir" != "/" ]]; do
235
+ if [[ -e "$dir/.git" ]]; then
236
+ printf '%s' "$dir"
237
+ return 0
238
+ fi
239
+ dir=$(dirname "$dir" 2>/dev/null) || return 0
240
+ done
241
+ return 0
242
+ }
243
+
244
+ # Keep only the six known keys whose values are non-empty strings, cap each,
245
+ # and emit NOTHING when nothing usable remains — so an empty or scope-free
246
+ # object is never passed as if it were a label. Malformed JSON makes jq exit
247
+ # non-zero with no output, which lands in the same "no scope" branch.
248
+ #
249
+ # `-s` (slurp) is load-bearing, not a style choice. jq accepts a STREAM of JSON
250
+ # documents: without `-s` the filter runs once per document and `jq -c` prints
251
+ # one line each, so a config holding two concatenated or newline-separated
252
+ # objects produced a MULTI-LINE string that was passed verbatim as one
253
+ # --code-context argument. parseCodeContextFlag() (cli/src/commands/memories.ts)
254
+ # JSON.parse()s it and process.exit(1)s before any network call. In THIS hook
255
+ # that loss is unrecoverable: the transcript the capture would have summarized
256
+ # is compacted away immediately afterwards. The `!= "{}"` guard below did not
257
+ # catch it either: `{"zzz":1}{"team":"b"}` normalizes to the two lines
258
+ # `{}` + `{"team":"b"}`, and that whole string is not `{}`. Slurping into an
259
+ # array and taking the first element means exactly one line can ever be emitted.
260
+ #
261
+ # The cap is 100 CODEPOINTS, deliberately below the server's 200. The server's
262
+ # CodeContextSchema uses Zod `.max(200)`, which counts UTF-16 code units, while
263
+ # jq's `[0:N]` slices by codepoint. Every astral-plane character (emoji, CJK
264
+ # extensions, mathematical alphanumerics) is 1 codepoint but 2 UTF-16 units, so
265
+ # a `[0:200]` cap emitted values of up to 400 units — over the server maximum,
266
+ # a 400 from the API, and the same silent capture loss. Since no codepoint
267
+ # exceeds 2 UTF-16 units, 100 codepoints can never exceed 200 units. Scope
268
+ # labels are short names (product/team/service); the extra truncation is
269
+ # unreachable in practice, and truncating is recoverable where a rejected
270
+ # request is not.
271
+ normalize_code_context() { # JSON on stdin -> normalized JSON object, or nothing
272
+ local out
273
+ out=$(jq -s -c '
274
+ (.[0] // {}) as $raw
275
+ | if ($raw | type) == "object" then
276
+ reduce ("product","repo","service","component","team","role") as $k ({};
277
+ if ($raw[$k] | type) == "string" and ($raw[$k] | length) > 0
278
+ then .[$k] = ($raw[$k][0:100])
279
+ else . end)
280
+ else {} end
281
+ ' 2>/dev/null) || return 0
282
+ # The newline test is belt-and-braces: `-s` already guarantees one document,
283
+ # and jq -c escapes newlines inside string values. Nothing multi-line may ever
284
+ # reach --code-context again, whatever the filter above does.
285
+ if [[ -n "$out" && "$out" != "{}" && "$out" != "null" && "$out" != *$'\n'* ]]; then
286
+ printf '%s' "$out"
287
+ fi
288
+ return 0
289
+ }
290
+
291
+ CODE_CONTEXT_JSON=""
292
+
293
+ # 1. Env var. Tested for presence only — the value is never echoed or logged.
294
+ if [[ -n "${MX_CODE_CONTEXT:-}" ]]; then
295
+ CODE_CONTEXT_JSON=$(printf '%s' "$MX_CODE_CONTEXT" | normalize_code_context) || CODE_CONTEXT_JSON=""
296
+ fi
297
+
298
+ # 2. Repo file — consulted only when the env var yielded nothing usable.
299
+ if [[ -z "$CODE_CONTEXT_JSON" ]]; then
300
+ # ORDER IS LOAD-BEARING: harness-provided project dir, then the worktree path,
301
+ # then the cwd. Each source is TRIED IN TURN, and the transcript's directory
302
+ # is not a source at all. Both properties are corrections to earlier forms and
303
+ # both are pinned by the suite; read this before rewriting the expression.
304
+ #
305
+ # WHY EACH SOURCE IS TRIED SEPARATELY. The earlier form collapsed all three
306
+ # into ONE call: `find_project_root "${CLAUDE_PROJECT_DIR:-${CLAUDE_WORKTREE_PATH:-$(pwd)}}"`.
307
+ # `:-` substitutes only when a variable is UNSET or EMPTY — never when it is
308
+ # set to something useless. So a CLAUDE_PROJECT_DIR that is set but resolves
309
+ # to no repository (a stale path, a deleted directory, a path outside any
310
+ # checkout) consumed the single attempt and the cwd was NEVER consulted.
311
+ # Measured: CLAUDE_PROJECT_DIR=/proc and =/nonexistent/zz each produced
312
+ # {"product":"home","team":"DOTFILES-WRONG-SCOPE"} from the $HOME config.
313
+ # The loop below consults the next source instead, which `:-` cannot express.
314
+ #
315
+ # WHAT IS ACTUALLY KNOWN ABOUT CLAUDE_PROJECT_DIR IN THE CUSTOMER CONTEXT.
316
+ # It is the documented hook variable, and it is the right first source when it
317
+ # is present. This repo does NOT evidence that it is set for hooks installed
318
+ # by `mx mcp install`: every other reference to it is fleet code, and it was
319
+ # measured UNSET in a live Claude Code tool process (a tool process is not a
320
+ # hook process, so that is not disproof — but it is not confirmation either).
321
+ # The expression below is therefore designed to be CORRECT WHEN IT IS ABSENT:
322
+ # an unset or empty value is skipped by the loop and the cwd carries the case.
323
+ # Do not restructure this so that a present CLAUDE_PROJECT_DIR is required.
324
+ # WHY THE TRANSCRIPT DIRECTORY IS NOT A SOURCE. It has no legitimate resolving
325
+ # power, and the reason is DEPTH-INDEPENDENT — do not restate it as a claim
326
+ # about the transcript's nesting. An earlier version of this comment argued
327
+ # from a flat ~/.claude/projects/<slug>/<id>.jsonl layout and enumerated the
328
+ # three directories a walk could reach. That premise is false here. Measured
329
+ # on this machine: transcripts sit at BOTH depth 2 and depth 4 under
330
+ # ~/.claude/projects — 310 at depth 2, 5621 deeper — so any argument of the
331
+ # form "the walk can only ever reach these three named directories" does not
332
+ # hold. The premise that does hold, and that does not depend on depth:
333
+ #
334
+ # .git anywhere under ~/.claude, any depth: 0 (also no .hg, no .svn)
335
+ #
336
+ # An upward walk from any transcript directory traverses only directories
337
+ # inside ~/.claude, none of which carries a repository marker, so it cannot
338
+ # terminate inside that subtree at any depth. That leaves $HOME as its ONLY
339
+ # reachable non-empty outcome, which is precisely the defect this block exists
340
+ # to prevent: keeping dotfiles in git is common, so $HOME/.git exists on many
341
+ # machines, and with $HOME/.mx-code-context.json present every capture from
342
+ # every worktree on the machine was labelled from the home config. A
343
+ # confidently wrong label is worse than no label — invisible to the scope that
344
+ # should see it, visible to one that must not. Do not reintroduce it.
345
+ #
346
+ # CAVEAT, stated because this is a claim about a directory OUTSIDE this
347
+ # repository: it could stop being true, and nothing in this codebase would
348
+ # notice if a repository ever appeared under ~/.claude. Removing the leg does
349
+ # not depend on the caveat holding — the leg is simply gone — but any future
350
+ # argument to reinstate it must re-measure rather than cite this line.
351
+ #
352
+ # WHY $HOME IS REFUSED OUTRIGHT, AND WHY BOTH SIDES ARE CANONICALIZED. Belt
353
+ # and braces for the residual where the cwd itself is $HOME or a directory
354
+ # beneath it whose nearest .git is $HOME's. It also closes a consequence that
355
+ # is easy to miss: CC_ROOT is the basis of the symlink containment test below,
356
+ # so a CC_ROOT of $HOME silently widens "contained in the project" to
357
+ # "anywhere in the home directory", and $HOME/.mx-code-context.json symlinked
358
+ # to any other file under $HOME would resolve and be used. The cost is not
359
+ # supporting a user whose real project IS their home directory; that is the
360
+ # same confusion we want rejected.
361
+ #
362
+ # The comparison MUST canonicalize $HOME. CC_ROOT always arrives canonical —
363
+ # find_project_root builds it with `cd … && pwd -P` — while $HOME arrives
364
+ # verbatim from the environment, so a literal `==` against "$HOME" misses on
365
+ # every spelling that is not byte-identical. Four spellings were measured
366
+ # leaking the home label through a symlinked $HOME/.mx-code-context.json: a
367
+ # trailing slash ("/home/x/"), a "/." suffix ("/home/x/."), $HOME set to a
368
+ # SYMLINK to the real home, and a cwd reached through that same symlink.
369
+ # Symlinked home directories are routine — macOS firmlinks, NFS corporate
370
+ # homes, container images mapping /home/user onto another volume — so the
371
+ # trigger is a normal configuration and the miss is silent: the refusal does
372
+ # not fire, and the containment widening above returns with it. `cd … && pwd -P`
373
+ # is the same primitive find_project_root uses and handles all four in one step.
374
+ #
375
+ # HOME unset, or pointing at nothing, leaves _home_real empty and no refusal
376
+ # fires. That is unchanged from the previous `-n "${HOME:-}"` guard, not a
377
+ # regression. The `|| _home_real=""` is REQUIRED, not decorative: without it a
378
+ # failing `cd` leaves the assignment non-zero and last on its path, which fires
379
+ # the ERR trap and abandons the capture.
380
+ #
381
+ # ERR-TRAP SAFETY — verified, not assumed, because a regression here fires
382
+ # `trap 'exit 0' ERR` and silently abandons the capture. The loop body's only
383
+ # status-bearing statement is a `$(...)` assignment of find_project_root, which
384
+ # returns 0 on every path including the `cd` failure. The break is written as
385
+ # an `if` block for the same reason the $HOME refusal is. The earlier one-line
386
+ # form `[[ -n "$CC_ROOT" ]] && break` returns 1 whenever the loop CONTINUES —
387
+ # an earlier version of this comment claimed it returned 0, which was wrong. It
388
+ # was safe only POSITIONALLY: as a non-final command of an `&&` list it is
389
+ # exempt under the errexit rules, so nothing fired. That safety depends on
390
+ # where the statement sits and would evaporate the moment this loop became the
391
+ # last statement of a function: the function would return 1 and any `$(...)`
392
+ # call of it would fire the trap. The `if` form returns 0 and removes the
393
+ # positional dependence.
394
+ #
395
+ # The same reasoning is why the $HOME refusal is an `if` block ON PURPOSE: the
396
+ # one-line `[[ "$CC_ROOT" == "$_home_real" ]] && CC_ROOT=""` returns 1 when the
397
+ # test is false, and was measured firing the ERR trap when it landed last in an
398
+ # enclosing block. `pwd 2>/dev/null` keeps a deleted cwd from printing "error
399
+ # retrieving current directory" onto the hook's real stderr. This safety comes
400
+ # from find_project_root always returning 0, not from this hook omitting
401
+ # `set -E`, so it survives someone adding `set -E` later. Do not introduce a
402
+ # bare command on this path.
403
+ CC_ROOT=""
404
+ for _cand in "${CLAUDE_PROJECT_DIR:-}" "${CLAUDE_WORKTREE_PATH:-}" "$(pwd 2>/dev/null)"; do
405
+ [[ -n "$_cand" ]] || continue
406
+ CC_ROOT=$(find_project_root "$_cand")
407
+ if [[ -n "$CC_ROOT" ]]; then break; fi
408
+ done
409
+ _home_real=$(cd "${HOME:-/nonexistent}" 2>/dev/null && pwd -P) || _home_real=""
410
+ if [[ -n "$_home_real" && "$CC_ROOT" == "$_home_real" ]]; then
411
+ CC_ROOT=""
412
+ fi
413
+ CC_FILE="${CC_ROOT}/.mx-code-context.json"
414
+ if [[ -n "$CC_ROOT" ]]; then
415
+ # CWE-59. `-f`, `-r` and the read all follow symlinks, so a repo could check
416
+ # in `.mx-code-context.json` as a symlink to any file on the user's machine
417
+ # and the six allowlisted keys of it would be uploaded to the user's own
418
+ # MemNexus account. Resolve the path and require the result to stay inside
419
+ # the project root — the containment this same hook already applies to the
420
+ # transcript path earlier. Read through the RESOLVED path, so the link is
421
+ # not traversed a second time.
422
+ CC_REAL=$(realpath "$CC_FILE" 2>/dev/null || printf '')
423
+ if [[ -n "$CC_REAL" && "$CC_REAL" == "$CC_ROOT"/* && -f "$CC_REAL" && -r "$CC_REAL" ]]; then
424
+ # One open, bounded at the source (same 64KB bound as the TS resolver).
425
+ # The previous form size-checked with `wc -c` and then re-opened for jq:
426
+ # two opens on a path an untrusted repo controls, so a concurrent writer
427
+ # could present a small file to `wc` and a huge one to `jq` (TOCTOU) on a
428
+ # hot path. `head -c` bounds the read itself, which changes >64KB from
429
+ # "skipped" to "truncated, then rejected as invalid JSON" — the same
430
+ # observable outcome, no scope.
431
+ #
432
+ # `2>/dev/null` precedes the input redirect ON PURPOSE. Redirections apply
433
+ # left to right, so with `<"$CC_REAL" 2>/dev/null` a file that turned
434
+ # unreadable between the `-r` test and the open makes bash print the full
435
+ # path and "Permission denied" to the hook's REAL stderr, before the
436
+ # discard takes effect. Verified: reversing these two words reintroduces
437
+ # that leak. Do not reorder.
438
+ #
439
+ # The bytes are PIPED into jq, never routed through a variable. Bash
440
+ # strips NUL bytes out of command-substitution output and warns while
441
+ # doing it — from the parent shell reading the substitution, so the
442
+ # `2>/dev/null` above does not suppress that warning. Via a variable,
443
+ # `{"team":"a<NUL>b"}` printed a warning naming this script and line to
444
+ # the hook's real stderr, and then RESOLVED to team "ab": input the file
445
+ # does not contain, silently repaired before validation instead of
446
+ # rejected by it. Piping keeps the NUL inside jq, which rejects the
447
+ # document as malformed. Do not reintroduce an intermediate variable.
448
+ CODE_CONTEXT_JSON=$(head -c "$CODE_CONTEXT_MAX_BYTES" 2>/dev/null <"$CC_REAL" | normalize_code_context) || CODE_CONTEXT_JSON=""
449
+ fi
450
+ fi
451
+ fi
452
+
453
+ # Nothing resolved -> pass nothing at all, so the create command line is
454
+ # byte-identical to the pre-scope version. The `[@]+` guard keeps an empty array
455
+ # safe to expand under `set -u` on bash < 4.4.
456
+ CODE_CONTEXT_ARGS=()
457
+ if [[ -n "$CODE_CONTEXT_JSON" ]]; then
458
+ CODE_CONTEXT_ARGS=(--code-context "$CODE_CONTEXT_JSON")
459
+ fi
460
+
204
461
  # ── 7. Save memory (with timeout — never block compaction) ───────────────
205
462
  TIMEOUT_CMD=""
206
463
  if command -v timeout >/dev/null 2>&1; then
@@ -219,6 +476,7 @@ $TIMEOUT_CMD mx memories create \
219
476
  --conversation-id "NEW" \
220
477
  --content "$CONTENT" \
221
478
  --topics "auto-captured,precompact" \
479
+ "${CODE_CONTEXT_ARGS[@]+"${CODE_CONTEXT_ARGS[@]}"}" \
222
480
  >/dev/null 2>&1 || CREATE_STATUS=$?
223
481
 
224
482
  if [[ "$CREATE_STATUS" -ne 0 ]]; then
@@ -237,6 +237,261 @@ fi
237
237
  # Truncate to ~5000 chars
238
238
  CONTENT=$(echo "$CONTENT" | head -c 5000)
239
239
 
240
+ # ── 6b. Resolve project scope (codeContext) ──────────────────────────────
241
+ # Auto-captured memories carried no scope at all, so they leaked into every
242
+ # team's scoped searches and polluted build_context (#5158, #4957). PR #3281
243
+ # fixed that for the commit path only; this is the same resolution for the
244
+ # capture hooks, using the same sources and the same precedence.
245
+ #
246
+ # Mirrors resolveCommitCodeContext() in cli/src/lib/commit-context/code-context.ts:
247
+ # 1. MX_CODE_CONTEXT env var — operator-controlled, higher trust. It wins over
248
+ # the file (CWE-345): a checked-in file from an untrusted clone must not
249
+ # override the scope the operator set explicitly.
250
+ # 2. <projectRoot>/.mx-code-context.json — repo-controlled, lower trust.
251
+ # 3. Neither present or neither usable -> NO scope. The memory is created
252
+ # unlabeled, exactly as it was before this block existed. No default is
253
+ # invented: a guessed label is invisible to the scope that should see it
254
+ # and visible to one that should not, which is worse than no label.
255
+ #
256
+ # Every command in this block must end up returning 0. The EXIT/ERR trap set in
257
+ # section 5 exits the hook, so a non-zero status here would abandon the capture
258
+ # over a missing config file — a far worse regression than the unlabeled
259
+ # memories this fixes. The absent-config path is the common one today.
260
+ CODE_CONTEXT_MAX_BYTES=65536
261
+
262
+ # Walk up for the git project root, mirroring findProjectRoot() in
263
+ # cli/src/lib/commit-context/local-cache.ts. `-e`, not `-d`: inside a git
264
+ # worktree `.git` is a FILE, and the worktree root — not the main repo — is the
265
+ # project root (the TS uses fs.existsSync, which makes no such distinction).
266
+ find_project_root() { # <start-dir> -> project root on stdout, or nothing
267
+ local dir
268
+ dir=$(cd "$1" 2>/dev/null && pwd -P) || return 0
269
+ while [[ -n "$dir" && "$dir" != "/" ]]; do
270
+ if [[ -e "$dir/.git" ]]; then
271
+ printf '%s' "$dir"
272
+ return 0
273
+ fi
274
+ dir=$(dirname "$dir" 2>/dev/null) || return 0
275
+ done
276
+ return 0
277
+ }
278
+
279
+ # Keep only the six known keys whose values are non-empty strings, cap each,
280
+ # and emit NOTHING when nothing usable remains — so an empty or scope-free
281
+ # object is never passed as if it were a label. Malformed JSON makes jq exit
282
+ # non-zero with no output, which lands in the same "no scope" branch.
283
+ #
284
+ # `-s` (slurp) is load-bearing, not a style choice. jq accepts a STREAM of JSON
285
+ # documents: without `-s` the filter runs once per document and `jq -c` prints
286
+ # one line each, so a config holding two concatenated or newline-separated
287
+ # objects produced a MULTI-LINE string that was passed verbatim as one
288
+ # --code-context argument. parseCodeContextFlag() (cli/src/commands/memories.ts)
289
+ # JSON.parse()s it and process.exit(1)s before any network call — the capture is
290
+ # lost silently, triggered by cloning a repo. The `!= "{}"` guard below did not
291
+ # catch it either: `{"zzz":1}{"team":"b"}` normalizes to the two lines
292
+ # `{}` + `{"team":"b"}`, and that whole string is not `{}`. Slurping into an
293
+ # array and taking the first element means exactly one line can ever be emitted.
294
+ #
295
+ # The cap is 100 CODEPOINTS, deliberately below the server's 200. The server's
296
+ # CodeContextSchema uses Zod `.max(200)`, which counts UTF-16 code units, while
297
+ # jq's `[0:N]` slices by codepoint. Every astral-plane character (emoji, CJK
298
+ # extensions, mathematical alphanumerics) is 1 codepoint but 2 UTF-16 units, so
299
+ # a `[0:200]` cap emitted values of up to 400 units — over the server maximum,
300
+ # a 400 from the API, and the same silent capture loss. Since no codepoint
301
+ # exceeds 2 UTF-16 units, 100 codepoints can never exceed 200 units. Scope
302
+ # labels are short names (product/team/service); the extra truncation is
303
+ # unreachable in practice, and truncating is recoverable where a rejected
304
+ # request is not.
305
+ normalize_code_context() { # JSON on stdin -> normalized JSON object, or nothing
306
+ local out
307
+ out=$(jq -s -c '
308
+ (.[0] // {}) as $raw
309
+ | if ($raw | type) == "object" then
310
+ reduce ("product","repo","service","component","team","role") as $k ({};
311
+ if ($raw[$k] | type) == "string" and ($raw[$k] | length) > 0
312
+ then .[$k] = ($raw[$k][0:100])
313
+ else . end)
314
+ else {} end
315
+ ' 2>/dev/null) || return 0
316
+ # The newline test is belt-and-braces: `-s` already guarantees one document,
317
+ # and jq -c escapes newlines inside string values. Nothing multi-line may ever
318
+ # reach --code-context again, whatever the filter above does.
319
+ if [[ -n "$out" && "$out" != "{}" && "$out" != "null" && "$out" != *$'\n'* ]]; then
320
+ printf '%s' "$out"
321
+ fi
322
+ return 0
323
+ }
324
+
325
+ CODE_CONTEXT_JSON=""
326
+
327
+ # 1. Env var. Tested for presence only — the value is never echoed or logged.
328
+ if [[ -n "${MX_CODE_CONTEXT:-}" ]]; then
329
+ CODE_CONTEXT_JSON=$(printf '%s' "$MX_CODE_CONTEXT" | normalize_code_context) || CODE_CONTEXT_JSON=""
330
+ fi
331
+
332
+ # 2. Repo file — consulted only when the env var yielded nothing usable.
333
+ if [[ -z "$CODE_CONTEXT_JSON" ]]; then
334
+ # ORDER IS LOAD-BEARING: harness-provided project dir, then the worktree path,
335
+ # then the cwd. Each source is TRIED IN TURN, and the transcript's directory
336
+ # is not a source at all. Both properties are corrections to earlier forms and
337
+ # both are pinned by the suite; read this before rewriting the expression.
338
+ #
339
+ # WHY EACH SOURCE IS TRIED SEPARATELY. The earlier form collapsed all three
340
+ # into ONE call: `find_project_root "${CLAUDE_PROJECT_DIR:-${CLAUDE_WORKTREE_PATH:-$(pwd)}}"`.
341
+ # `:-` substitutes only when a variable is UNSET or EMPTY — never when it is
342
+ # set to something useless. So a CLAUDE_PROJECT_DIR that is set but resolves
343
+ # to no repository (a stale path, a deleted directory, a path outside any
344
+ # checkout) consumed the single attempt and the cwd was NEVER consulted.
345
+ # Measured: CLAUDE_PROJECT_DIR=/proc and =/nonexistent/zz each produced
346
+ # {"product":"home","team":"DOTFILES-WRONG-SCOPE"} from the $HOME config.
347
+ # The loop below consults the next source instead, which `:-` cannot express.
348
+ #
349
+ # WHAT IS ACTUALLY KNOWN ABOUT CLAUDE_PROJECT_DIR IN THE CUSTOMER CONTEXT.
350
+ # It is the documented hook variable, and it is the right first source when it
351
+ # is present. This repo does NOT evidence that it is set for hooks installed
352
+ # by `mx mcp install`: every other reference to it is fleet code, and it was
353
+ # measured UNSET in a live Claude Code tool process (a tool process is not a
354
+ # hook process, so that is not disproof — but it is not confirmation either).
355
+ # The expression below is therefore designed to be CORRECT WHEN IT IS ABSENT:
356
+ # an unset or empty value is skipped by the loop and the cwd carries the case.
357
+ # Do not restructure this so that a present CLAUDE_PROJECT_DIR is required.
358
+ # WHY THE TRANSCRIPT DIRECTORY IS NOT A SOURCE. It has no legitimate resolving
359
+ # power, and the reason is DEPTH-INDEPENDENT — do not restate it as a claim
360
+ # about the transcript's nesting. An earlier version of this comment argued
361
+ # from a flat ~/.claude/projects/<slug>/<id>.jsonl layout and enumerated the
362
+ # three directories a walk could reach. That premise is false here. Measured
363
+ # on this machine: transcripts sit at BOTH depth 2 and depth 4 under
364
+ # ~/.claude/projects — 310 at depth 2, 5621 deeper — so any argument of the
365
+ # form "the walk can only ever reach these three named directories" does not
366
+ # hold. The premise that does hold, and that does not depend on depth:
367
+ #
368
+ # .git anywhere under ~/.claude, any depth: 0 (also no .hg, no .svn)
369
+ #
370
+ # An upward walk from any transcript directory traverses only directories
371
+ # inside ~/.claude, none of which carries a repository marker, so it cannot
372
+ # terminate inside that subtree at any depth. That leaves $HOME as its ONLY
373
+ # reachable non-empty outcome, which is precisely the defect this block exists
374
+ # to prevent: keeping dotfiles in git is common, so $HOME/.git exists on many
375
+ # machines, and with $HOME/.mx-code-context.json present every capture from
376
+ # every worktree on the machine was labelled from the home config. A
377
+ # confidently wrong label is worse than no label — invisible to the scope that
378
+ # should see it, visible to one that must not. Do not reintroduce it.
379
+ #
380
+ # CAVEAT, stated because this is a claim about a directory OUTSIDE this
381
+ # repository: it could stop being true, and nothing in this codebase would
382
+ # notice if a repository ever appeared under ~/.claude. Removing the leg does
383
+ # not depend on the caveat holding — the leg is simply gone — but any future
384
+ # argument to reinstate it must re-measure rather than cite this line.
385
+ #
386
+ # WHY $HOME IS REFUSED OUTRIGHT, AND WHY BOTH SIDES ARE CANONICALIZED. Belt
387
+ # and braces for the residual where the cwd itself is $HOME or a directory
388
+ # beneath it whose nearest .git is $HOME's. It also closes a consequence that
389
+ # is easy to miss: CC_ROOT is the basis of the symlink containment test below,
390
+ # so a CC_ROOT of $HOME silently widens "contained in the project" to
391
+ # "anywhere in the home directory", and $HOME/.mx-code-context.json symlinked
392
+ # to any other file under $HOME would resolve and be used. The cost is not
393
+ # supporting a user whose real project IS their home directory; that is the
394
+ # same confusion we want rejected.
395
+ #
396
+ # The comparison MUST canonicalize $HOME. CC_ROOT always arrives canonical —
397
+ # find_project_root builds it with `cd … && pwd -P` — while $HOME arrives
398
+ # verbatim from the environment, so a literal `==` against "$HOME" misses on
399
+ # every spelling that is not byte-identical. Four spellings were measured
400
+ # leaking the home label through a symlinked $HOME/.mx-code-context.json: a
401
+ # trailing slash ("/home/x/"), a "/." suffix ("/home/x/."), $HOME set to a
402
+ # SYMLINK to the real home, and a cwd reached through that same symlink.
403
+ # Symlinked home directories are routine — macOS firmlinks, NFS corporate
404
+ # homes, container images mapping /home/user onto another volume — so the
405
+ # trigger is a normal configuration and the miss is silent: the refusal does
406
+ # not fire, and the containment widening above returns with it. `cd … && pwd -P`
407
+ # is the same primitive find_project_root uses and handles all four in one step.
408
+ #
409
+ # HOME unset, or pointing at nothing, leaves _home_real empty and no refusal
410
+ # fires. That is unchanged from the previous `-n "${HOME:-}"` guard, not a
411
+ # regression. The `|| _home_real=""` is REQUIRED, not decorative: without it a
412
+ # failing `cd` leaves the assignment non-zero and last on its path, which fires
413
+ # the ERR trap and abandons the capture.
414
+ #
415
+ # ERR-TRAP SAFETY — verified, not assumed, because a regression here fires
416
+ # `trap 'exit 0' ERR` and silently abandons the capture. The loop body's only
417
+ # status-bearing statement is a `$(...)` assignment of find_project_root, which
418
+ # returns 0 on every path including the `cd` failure. The break is written as
419
+ # an `if` block for the same reason the $HOME refusal is. The earlier one-line
420
+ # form `[[ -n "$CC_ROOT" ]] && break` returns 1 whenever the loop CONTINUES —
421
+ # an earlier version of this comment claimed it returned 0, which was wrong. It
422
+ # was safe only POSITIONALLY: as a non-final command of an `&&` list it is
423
+ # exempt under the errexit rules, so nothing fired. That safety depends on
424
+ # where the statement sits and would evaporate the moment this loop became the
425
+ # last statement of a function: the function would return 1 and any `$(...)`
426
+ # call of it would fire the trap. The `if` form returns 0 and removes the
427
+ # positional dependence.
428
+ #
429
+ # The same reasoning is why the $HOME refusal is an `if` block ON PURPOSE: the
430
+ # one-line `[[ "$CC_ROOT" == "$_home_real" ]] && CC_ROOT=""` returns 1 when the
431
+ # test is false, and was measured firing the ERR trap when it landed last in an
432
+ # enclosing block. `pwd 2>/dev/null` keeps a deleted cwd from printing "error
433
+ # retrieving current directory" onto the hook's real stderr. This safety comes
434
+ # from find_project_root always returning 0, not from this hook omitting
435
+ # `set -E`, so it survives someone adding `set -E` later. Do not introduce a
436
+ # bare command on this path.
437
+ CC_ROOT=""
438
+ for _cand in "${CLAUDE_PROJECT_DIR:-}" "${CLAUDE_WORKTREE_PATH:-}" "$(pwd 2>/dev/null)"; do
439
+ [[ -n "$_cand" ]] || continue
440
+ CC_ROOT=$(find_project_root "$_cand")
441
+ if [[ -n "$CC_ROOT" ]]; then break; fi
442
+ done
443
+ _home_real=$(cd "${HOME:-/nonexistent}" 2>/dev/null && pwd -P) || _home_real=""
444
+ if [[ -n "$_home_real" && "$CC_ROOT" == "$_home_real" ]]; then
445
+ CC_ROOT=""
446
+ fi
447
+ CC_FILE="${CC_ROOT}/.mx-code-context.json"
448
+ if [[ -n "$CC_ROOT" ]]; then
449
+ # CWE-59. `-f`, `-r` and the read all follow symlinks, so a repo could check
450
+ # in `.mx-code-context.json` as a symlink to any file on the user's machine
451
+ # and the six allowlisted keys of it would be uploaded to the user's own
452
+ # MemNexus account. Resolve the path and require the result to stay inside
453
+ # the project root — the containment this same hook already applies to the
454
+ # transcript path in section 2. Read through the RESOLVED path, so the link
455
+ # is not traversed a second time.
456
+ CC_REAL=$(realpath "$CC_FILE" 2>/dev/null || printf '')
457
+ if [[ -n "$CC_REAL" && "$CC_REAL" == "$CC_ROOT"/* && -f "$CC_REAL" && -r "$CC_REAL" ]]; then
458
+ # One open, bounded at the source (same 64KB bound as the TS resolver).
459
+ # The previous form size-checked with `wc -c` and then re-opened for jq:
460
+ # two opens on a path an untrusted repo controls, so a concurrent writer
461
+ # could present a small file to `wc` and a huge one to `jq` (TOCTOU) on a
462
+ # hot per-turn path. `head -c` bounds the read itself, which changes >64KB
463
+ # from "skipped" to "truncated, then rejected as invalid JSON" — the same
464
+ # observable outcome, no scope.
465
+ #
466
+ # `2>/dev/null` precedes the input redirect ON PURPOSE. Redirections apply
467
+ # left to right, so with `<"$CC_REAL" 2>/dev/null` a file that turned
468
+ # unreadable between the `-r` test and the open makes bash print the full
469
+ # path and "Permission denied" to the hook's REAL stderr, before the
470
+ # discard takes effect. Verified: reversing these two words reintroduces
471
+ # that leak. Do not reorder.
472
+ #
473
+ # The bytes are PIPED into jq, never routed through a variable. Bash
474
+ # strips NUL bytes out of command-substitution output and warns while
475
+ # doing it — from the parent shell reading the substitution, so the
476
+ # `2>/dev/null` above does not suppress that warning. Via a variable,
477
+ # `{"team":"a<NUL>b"}` printed a warning naming this script and line to
478
+ # the hook's real stderr, and then RESOLVED to team "ab": input the file
479
+ # does not contain, silently repaired before validation instead of
480
+ # rejected by it. Piping keeps the NUL inside jq, which rejects the
481
+ # document as malformed. Do not reintroduce an intermediate variable.
482
+ CODE_CONTEXT_JSON=$(head -c "$CODE_CONTEXT_MAX_BYTES" 2>/dev/null <"$CC_REAL" | normalize_code_context) || CODE_CONTEXT_JSON=""
483
+ fi
484
+ fi
485
+ fi
486
+
487
+ # Nothing resolved -> pass nothing at all, so the create command line is
488
+ # byte-identical to the pre-scope version. The `[@]+` guard keeps an empty array
489
+ # safe to expand under `set -u` on bash < 4.4.
490
+ CODE_CONTEXT_ARGS=()
491
+ if [[ -n "$CODE_CONTEXT_JSON" ]]; then
492
+ CODE_CONTEXT_ARGS=(--code-context "$CODE_CONTEXT_JSON")
493
+ fi
494
+
240
495
  # ── 7. Save memory (with timeout — never block session exit) ─────────────
241
496
  TIMEOUT_CMD=""
242
497
  if command -v timeout >/dev/null 2>&1; then
@@ -254,6 +509,7 @@ $TIMEOUT_CMD mx memories create \
254
509
  --conversation-id "NEW" \
255
510
  --content "$CONTENT" \
256
511
  --topics "auto-captured,session-summary" \
512
+ "${CODE_CONTEXT_ARGS[@]+"${CODE_CONTEXT_ARGS[@]}"}" \
257
513
  >/dev/null 2>&1 || CREATE_STATUS=$?
258
514
 
259
515
  # ── 8. Update debounce state (bounded retry) ─────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memnexus-ai/cli",
3
- "version": "1.7.233",
3
+ "version": "1.7.235",
4
4
  "description": "Command-line interface for MemNexus Core API",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "dist/",
24
24
  "bin/",
25
25
  "hooks/",
26
+ "!hooks/*.test.sh",
26
27
  "README.md",
27
28
  "CHANGELOG.md",
28
29
  "USAGE.md"
@@ -34,7 +35,7 @@
34
35
  "node": ">=18.0.0"
35
36
  },
36
37
  "dependencies": {
37
- "@memnexus-ai/typescript-sdk": "^1.75.2",
38
+ "@memnexus-ai/typescript-sdk": "^1.75.6",
38
39
  "@modelcontextprotocol/sdk": "^1.27.1",
39
40
  "axios": "^1.13.6",
40
41
  "chalk": "^5.3.0",
@@ -1,465 +0,0 @@
1
- #!/bin/bash
2
- # capture-hooks.test.sh — regression suite for the v4.04 error-honesty fixes in
3
- # the customer-shipped capture hooks (capture-session-end.sh,
4
- # capture-precompact.sh) and the dev-tools auto-save hook.
5
- #
6
- # Run with: bash cli/hooks/capture-hooks.test.sh
7
- # Exit: 0 = all pass, 1 = any fail.
8
- #
9
- # What is under test:
10
- # * a FAILED `mx memories create` must leave the debounce watermark alone, so
11
- # the segment is retried instead of being discarded forever
12
- # * a SUCCESSFUL create must advance the watermark
13
- # * the project-hash fallback chain must actually fall back (the old form
14
- # piped into awk and therefore tested awk's exit status, never the hasher's)
15
- # * with no hasher at all, the hook must skip rather than write a state file
16
- # shared by every project
17
- # * every hook still exits 0 in all of these cases (fail-open with Claude Code)
18
- set -u
19
-
20
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
- REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
22
- SESSION_END_HOOK="$SCRIPT_DIR/capture-session-end.sh"
23
- PRECOMPACT_HOOK="$SCRIPT_DIR/capture-precompact.sh"
24
- AUTOSAVE_HOOK="$REPO_ROOT/dev-tools/claude-hooks/auto-save-memory.sh"
25
-
26
- PASS=0
27
- FAIL=0
28
-
29
- ok() {
30
- PASS=$((PASS + 1))
31
- echo " PASS: $1"
32
- }
33
-
34
- nope() {
35
- FAIL=$((FAIL + 1))
36
- echo " FAIL: $1"
37
- [[ $# -gt 1 ]] && echo " $2"
38
- }
39
-
40
- assert_eq() { # <expected> <actual> <label>
41
- if [[ "$1" == "$2" ]]; then ok "$3"; else nope "$3" "expected [$1], got [$2]"; fi
42
- }
43
-
44
- assert_contains() { # <haystack> <needle> <label>
45
- if [[ "$1" == *"$2"* ]]; then ok "$3"; else nope "$3" "missing [$2] in: $1"; fi
46
- }
47
-
48
- # ── Fixtures ────────────────────────────────────────────────────────────────
49
-
50
- # A stub `mx` whose exit status is controlled by MX_STUB_EXIT. Every call is
51
- # appended to MX_STUB_LOG so a test can assert that no create was even
52
- # attempted (the bounded-retry cap).
53
- make_stub_bin() { # <dir>
54
- local bin="$1"
55
- mkdir -p "$bin"
56
- cat >"$bin/mx" <<'STUB'
57
- #!/bin/bash
58
- [[ -n "${MX_STUB_LOG:-}" ]] && echo "call" >>"$MX_STUB_LOG"
59
- exit "${MX_STUB_EXIT:-0}"
60
- STUB
61
- chmod +x "$bin/mx"
62
- }
63
-
64
- # A transcript with enough lines (>100) and tool calls (>=5) to clear the
65
- # hooks' debounce and triviality gates.
66
- make_transcript() { # <path> <lines>
67
- local path="$1" lines="${2:-150}" i
68
- : >"$path"
69
- for ((i = 0; i < lines; i++)); do
70
- printf '%s\n' '{"type":"user","message":{"content":"do the thing"}}' >>"$path"
71
- printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"file_path":"/tmp/x.ts"}},{"type":"text","text":"done"}]}}' >>"$path"
72
- done
73
- }
74
-
75
- # Run capture-session-end.sh in an isolated HOME/state dir.
76
- # Echoes the watermark file's contents (empty when absent).
77
- run_session_end() { # <workdir> <path-override> ; uses MX_STUB_EXIT
78
- local work="$1" path_override="$2"
79
- local transcript="$work/transcript.jsonl"
80
- ( cd "$work" && PATH="$path_override" HOME="$work/home" XDG_RUNTIME_DIR="$work/state" \
81
- MX_STUB_LOG="$work/mx-calls.log" \
82
- bash "$SESSION_END_HOOK" \
83
- <<<"{\"transcript_path\":\"$transcript\",\"session_id\":\"s1\",\"stop_hook_active\":false}" \
84
- 2>"$work/stderr.txt" )
85
- echo "$?" >"$work/exit.txt"
86
- }
87
-
88
- # Contents of the watermark file (empty when absent). The sibling ".fails"
89
- # counter is excluded — it lives next to the watermark and must not be mistaken
90
- # for it. Globs, not `ls`: an unmatched glob yields the literal pattern, which
91
- # the -f test filters out.
92
- state_file_contents() { # <workdir>
93
- local f
94
- for f in "$1"/state/.mx-session-capture-*; do
95
- [[ -f "$f" ]] || continue
96
- [[ "$f" == *.fails ]] && continue
97
- cat "$f"
98
- return 0
99
- done
100
- return 0
101
- }
102
-
103
- fail_count_contents() { # <workdir>
104
- local f
105
- for f in "$1"/state/.mx-session-capture-*.fails; do
106
- [[ -f "$f" ]] || continue
107
- cat "$f"
108
- return 0
109
- done
110
- return 0
111
- }
112
-
113
- # Space-separated basenames of everything in the state dir — failure diagnostics
114
- # only. Dotfiles are matched explicitly because the state files are hidden.
115
- list_state_dir() { # <workdir>
116
- local f out=""
117
- for f in "$1"/state/* "$1"/state/.[!.]*; do
118
- [[ -e "$f" ]] || continue
119
- out+="$(basename "$f") "
120
- done
121
- printf '%s' "$out"
122
- }
123
-
124
- # Number of `mx` invocations recorded by the stub (0 when it was never called).
125
- count_mx_calls() { # <workdir>
126
- local log="$1/mx-calls.log"
127
- [[ -f "$log" ]] || { echo 0; return 0; }
128
- wc -l <"$log" | tr -d ' '
129
- }
130
-
131
- REAL_PATH="$PATH"
132
-
133
- # Build a shadow PATH directory: every executable on the current PATH symlinked
134
- # in, MINUS the named tools. Used to simulate a machine without md5sum (or
135
- # without any hasher) while leaving the rest of the environment intact.
136
- make_shadow_path() { # <dir> <tool-to-omit>...
137
- local dir="$1"; shift
138
- local omit=" $* "
139
- local d f name
140
- mkdir -p "$dir"
141
- local IFS=:
142
- for d in $REAL_PATH; do
143
- [[ -d "$d" ]] || continue
144
- for f in "$d"/*; do
145
- [[ -x "$f" && ! -d "$f" ]] || continue
146
- name="$(basename "$f")"
147
- [[ "$omit" == *" $name "* ]] && continue
148
- [[ -e "$dir/$name" ]] && continue
149
- ln -sf "$f" "$dir/$name"
150
- done
151
- done
152
- }
153
-
154
-
155
- echo "── capture-session-end.sh ──"
156
-
157
- # 1. Failed create → watermark unchanged.
158
- work=$(mktemp -d)
159
- make_stub_bin "$work/bin"
160
- make_transcript "$work/transcript.jsonl"
161
- mkdir -p "$work/state" "$work/home"
162
- MX_STUB_EXIT=1 run_session_end "$work" "$work/bin:$REAL_PATH"
163
- assert_eq "0" "$(cat "$work/exit.txt")" "fail-open: hook exits 0 when create fails"
164
- assert_eq "" "$(state_file_contents "$work")" "failed create leaves the watermark unwritten"
165
- assert_contains "$(cat "$work/stderr.txt")" "mx memories create failed" \
166
- "failed create writes one stderr diagnostic"
167
- rm -rf "$work"
168
-
169
- # 2. Successful create → watermark advances to the transcript's line count.
170
- work=$(mktemp -d)
171
- make_stub_bin "$work/bin"
172
- make_transcript "$work/transcript.jsonl"
173
- mkdir -p "$work/state" "$work/home"
174
- MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$REAL_PATH"
175
- assert_eq "0" "$(cat "$work/exit.txt")" "hook exits 0 on success"
176
- assert_eq "300" "$(state_file_contents "$work")" "successful create advances the watermark"
177
- rm -rf "$work"
178
-
179
- # 3. Retry semantics: a failed run followed by a successful one saves the same
180
- # segment (the watermark only moves on the success).
181
- work=$(mktemp -d)
182
- make_stub_bin "$work/bin"
183
- make_transcript "$work/transcript.jsonl"
184
- mkdir -p "$work/state" "$work/home"
185
- MX_STUB_EXIT=1 run_session_end "$work" "$work/bin:$REAL_PATH"
186
- first="$(state_file_contents "$work")"
187
- MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$REAL_PATH"
188
- second="$(state_file_contents "$work")"
189
- assert_eq "" "$first" "first (failed) run: no watermark"
190
- assert_eq "300" "$second" "second (successful) run: watermark advances — segment was retried"
191
- rm -rf "$work"
192
-
193
- # 4. The default hasher is sha256sum (cksum was dropped from the chain: a
194
- # 32-bit CRC collision would make two projects share a state file).
195
- work=$(mktemp -d)
196
- make_stub_bin "$work/bin"
197
- make_transcript "$work/transcript.jsonl"
198
- mkdir -p "$work/state" "$work/home"
199
- MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$REAL_PATH"
200
- expected_hash=$(printf '%s' "$work" | sha256sum | awk '{print $1}')
201
- if [[ -f "$work/state/.mx-session-capture-${expected_hash}" ]]; then
202
- ok "state file is keyed by the sha256 of the project dir"
203
- else
204
- nope "state file is keyed by the sha256 of the project dir" \
205
- "state dir holds: $(list_state_dir "$work")"
206
- fi
207
- rm -rf "$work"
208
-
209
- # 4b. sha256sum absent → the md5sum fallback engages (the old awk-piped chain
210
- # silently produced an EMPTY hash shared by every project).
211
- work=$(mktemp -d)
212
- make_stub_bin "$work/bin"
213
- make_transcript "$work/transcript.jsonl"
214
- mkdir -p "$work/state" "$work/home"
215
- make_shadow_path "$work/nosha" sha256sum
216
- MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$work/nosha"
217
- expected_hash=$(printf '%s' "$work" | md5sum | awk '{print $1}')
218
- if [[ -f "$work/state/.mx-session-capture-${expected_hash}" ]]; then
219
- ok "sha256sum absent: md5sum fallback produces the project-specific state file"
220
- else
221
- nope "sha256sum absent: md5sum fallback engages" \
222
- "state dir holds: $(list_state_dir "$work")"
223
- fi
224
- rm -rf "$work"
225
-
226
- # 4c. cksum alone is NOT accepted (32-bit CRC — collisions are constructible).
227
- work=$(mktemp -d)
228
- make_stub_bin "$work/bin"
229
- make_transcript "$work/transcript.jsonl"
230
- mkdir -p "$work/state" "$work/home"
231
- make_shadow_path "$work/onlycksum" sha256sum md5sum shasum b2sum
232
- MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$work/onlycksum"
233
- assert_eq "" "$(ls -A "$work/state" 2>/dev/null)" "cksum-only machine: skips rather than using a 32-bit CRC"
234
- rm -rf "$work"
235
-
236
- # 5. No hasher at all → skip with a diagnostic, and never write a shared
237
- # (hash-less) state file that would suppress every other project.
238
- work=$(mktemp -d)
239
- make_stub_bin "$work/bin"
240
- make_transcript "$work/transcript.jsonl"
241
- mkdir -p "$work/state" "$work/home"
242
- make_shadow_path "$work/nohash" sha256sum md5sum shasum b2sum cksum
243
- MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$work/nohash"
244
- assert_eq "0" "$(cat "$work/exit.txt")" "no hasher: hook still exits 0"
245
- assert_eq "" "$(ls -A "$work/state" 2>/dev/null)" "no hasher: no shared state file is written"
246
- assert_contains "$(cat "$work/stderr.txt")" "no usable hash tool" \
247
- "no hasher: stderr diagnostic explains the skip"
248
- rm -rf "$work"
249
-
250
- # ── Bounded retry (security review Must Fix) ────────────────────────────────
251
- # Leaving the watermark in place on failure removed the only bound on retry:
252
- # LINES_SINCE_SAVE stays >=100 forever, so every turn re-ran the whole
253
- # extraction and another non-idempotent create. The retry is now capped.
254
-
255
- # 6. Five consecutive retryable failures (exit 5): work stops after the cap.
256
- work=$(mktemp -d)
257
- make_stub_bin "$work/bin"
258
- make_transcript "$work/transcript.jsonl"
259
- mkdir -p "$work/state" "$work/home"
260
- # Per-run observations, indexed by run number. Arrays rather than eval'd
261
- # variable names so the assignment is traceable (by shellcheck and by a reader).
262
- declare -a watermark_after=() calls_after=() stderr_after=()
263
- for run in 1 2 3 4 5; do
264
- MX_STUB_EXIT=5 run_session_end "$work" "$work/bin:$REAL_PATH"
265
- watermark_after[run]="$(state_file_contents "$work")"
266
- calls_after[run]="$(count_mx_calls "$work")"
267
- stderr_after[run]="$(cat "$work/stderr.txt")"
268
- done
269
- assert_eq "" "${watermark_after[1]}" "retryable failure 1/3: watermark not advanced"
270
- assert_eq "" "${watermark_after[2]}" "retryable failure 2/3: watermark not advanced"
271
- assert_eq "300" "${watermark_after[3]}" "retryable failure 3/3: watermark advanced (cap reached)"
272
- assert_contains "${stderr_after[2]}" "retry 2/3 next trigger" "retry counter is reported on stderr"
273
- assert_contains "${stderr_after[3]}" "giving up" "cap is announced on stderr"
274
- assert_eq "3" "${calls_after[3]}" "3 create attempts by the cap"
275
- assert_eq "3" "${calls_after[5]}" "runs 4 and 5 attempt no create at all"
276
- assert_eq "" "$(fail_count_contents "$work")" "fail counter is cleared once the segment is dropped"
277
- rm -rf "$work"
278
-
279
- # 7. Terminal statuses are never retried — the watermark advances immediately.
280
- # 2 = validation (the request is malformed), 3 = auth, 124 = timeout (the
281
- # server may already have persisted the memory; a retry would duplicate it,
282
- # and the create is non-idempotent).
283
- for terminal in 2 3 124; do
284
- work=$(mktemp -d)
285
- make_stub_bin "$work/bin"
286
- make_transcript "$work/transcript.jsonl"
287
- mkdir -p "$work/state" "$work/home"
288
- MX_STUB_EXIT="$terminal" run_session_end "$work" "$work/bin:$REAL_PATH"
289
- assert_eq "300" "$(state_file_contents "$work")" \
290
- "terminal exit ${terminal}: watermark advances on the first failure"
291
- assert_eq "" "$(fail_count_contents "$work")" "terminal exit ${terminal}: no fail counter written"
292
- assert_contains "$(cat "$work/stderr.txt")" "not retryable" \
293
- "terminal exit ${terminal}: stderr names it as not retryable"
294
- assert_contains "$(cat "$work/stderr.txt")" "exit ${terminal}" \
295
- "terminal exit ${terminal}: stderr names the exit code"
296
- rm -rf "$work"
297
- done
298
-
299
- # 8. A success after two failures clears the counter, so the next failure
300
- # streak starts from 1 rather than tripping the cap immediately.
301
- work=$(mktemp -d)
302
- make_stub_bin "$work/bin"
303
- make_transcript "$work/transcript.jsonl"
304
- mkdir -p "$work/state" "$work/home"
305
- MX_STUB_EXIT=5 run_session_end "$work" "$work/bin:$REAL_PATH"
306
- MX_STUB_EXIT=5 run_session_end "$work" "$work/bin:$REAL_PATH"
307
- assert_eq "2" "$(fail_count_contents "$work")" "two failures: counter is at 2"
308
- MX_STUB_EXIT=0 run_session_end "$work" "$work/bin:$REAL_PATH"
309
- assert_eq "300" "$(state_file_contents "$work")" "success after 2 failures: watermark advances"
310
- assert_eq "" "$(fail_count_contents "$work")" "success after 2 failures: fail counter cleared"
311
- rm -rf "$work"
312
-
313
- # 8b. The counter is the only bound on the retry. If it cannot be persisted —
314
- # the path exists but is not writable — the count silently resets to 0 every
315
- # trigger and the cap never trips. Inability to bound must be terminal.
316
- work=$(mktemp -d)
317
- make_stub_bin "$work/bin"
318
- make_transcript "$work/transcript.jsonl"
319
- mkdir -p "$work/state" "$work/home"
320
- # Occupy the counter path with a DIRECTORY so the write can never succeed.
321
- project_hash=$(printf '%s' "$work" | sha256sum | awk '{print $1}')
322
- mkdir -p "$work/state/.mx-session-capture-${project_hash}.fails"
323
- MX_STUB_EXIT=5 run_session_end "$work" "$work/bin:$REAL_PATH"
324
- assert_eq "300" "$(state_file_contents "$work")" \
325
- "unwritable counter: watermark advances on the FIRST failure"
326
- assert_contains "$(cat "$work/stderr.txt")" "cannot persist the retry counter" \
327
- "unwritable counter: stderr names the reason"
328
- calls_after_run1=$(count_mx_calls "$work")
329
- MX_STUB_EXIT=5 run_session_end "$work" "$work/bin:$REAL_PATH"
330
- assert_eq "1" "$calls_after_run1" "unwritable counter: run 1 attempted exactly one create"
331
- assert_eq "1" "$(count_mx_calls "$work")" \
332
- "unwritable counter: run 2 attempts no create"
333
- rm -rf "$work"
334
-
335
- echo "── capture-precompact.sh ──"
336
-
337
- # 6. Failed create → stderr diagnostic, still exit 0. (This hook has no
338
- # watermark; none was invented.)
339
- work2=$(mktemp -d)
340
- make_stub_bin "$work2/bin"
341
- make_transcript "$work2/transcript.jsonl"
342
- mkdir -p "$work2/home"
343
- ( cd "$work2" && PATH="$work2/bin:$REAL_PATH" HOME="$work2/home" MX_STUB_EXIT=1 \
344
- bash "$PRECOMPACT_HOOK" \
345
- <<<"{\"transcript_path\":\"$work2/transcript.jsonl\",\"session_id\":\"s1\",\"trigger\":\"auto\"}" \
346
- 2>"$work2/stderr.txt" >/dev/null )
347
- status2=$?
348
- assert_eq "0" "$status2" "precompact: exits 0 when create fails"
349
- assert_contains "$(cat "$work2/stderr.txt")" "mx memories create failed" \
350
- "precompact: failed create writes a stderr diagnostic"
351
- rm -rf "$work2"
352
-
353
- echo "── dev-tools/claude-hooks/auto-save-memory.sh ──"
354
-
355
- run_autosave() { # <workdir> ; uses MX_STUB_EXIT
356
- local work="$1"
357
- ( cd "$work" && PATH="$work/bin:$REAL_PATH" MX_AUTOSAVE_STATE_FILE="$work/state-line" \
358
- MX_STUB_LOG="$work/mx-calls.log" \
359
- bash "$AUTOSAVE_HOOK" \
360
- <<<"{\"transcript_path\":\"$work/transcript.jsonl\",\"stop_hook_active\":false}" \
361
- 2>"$work/stderr.txt" >/dev/null )
362
- echo "$?" >"$work/exit.txt"
363
- }
364
-
365
- # 7. Failed create → the line-number watermark is not recorded, so the turn is
366
- # retried instead of being skipped as "already saved".
367
- work=$(mktemp -d)
368
- make_stub_bin "$work/bin"
369
- make_transcript "$work/transcript.jsonl"
370
- MX_STUB_EXIT=1 run_autosave "$work"
371
- assert_eq "0" "$(cat "$work/exit.txt")" "auto-save: exits 0 when create fails"
372
- if [[ -f "$work/state-line" ]]; then
373
- nope "auto-save: failed create leaves the watermark unwritten" \
374
- "state-line holds: $(cat "$work/state-line")"
375
- else
376
- ok "auto-save: failed create leaves the watermark unwritten"
377
- fi
378
- assert_contains "$(cat "$work/stderr.txt")" "mx memories create failed" \
379
- "auto-save: failed create writes a stderr diagnostic"
380
- rm -rf "$work"
381
-
382
- # 8. Successful create → the watermark is recorded.
383
- work=$(mktemp -d)
384
- make_stub_bin "$work/bin"
385
- make_transcript "$work/transcript.jsonl"
386
- MX_STUB_EXIT=0 run_autosave "$work"
387
- assert_eq "0" "$(cat "$work/exit.txt")" "auto-save: exits 0 on success"
388
- if [[ -s "$work/state-line" ]]; then
389
- ok "auto-save: successful create records the watermark"
390
- else
391
- nope "auto-save: successful create records the watermark" "state-line missing or empty"
392
- fi
393
- rm -rf "$work"
394
-
395
- # 9. auto-save: the same bounded retry. Five consecutive retryable failures
396
- # stop after the cap; terminal statuses advance immediately.
397
- work=$(mktemp -d)
398
- make_stub_bin "$work/bin"
399
- make_transcript "$work/transcript.jsonl"
400
- for run in 1 2 3 4 5; do
401
- MX_STUB_EXIT=5 run_autosave "$work"
402
- done
403
- if [[ -s "$work/state-line" ]]; then
404
- ok "auto-save: watermark advanced once the retry cap was reached"
405
- else
406
- nope "auto-save: watermark advanced once the retry cap was reached" "state-line missing"
407
- fi
408
- assert_eq "3" "$(count_mx_calls "$work")" \
409
- "auto-save: 3 create attempts across 5 runs (runs 4-5 attempt none)"
410
- if [[ -f "$work/state-line.fails" ]]; then
411
- nope "auto-save: fail counter cleared after giving up" "counter still present"
412
- else
413
- ok "auto-save: fail counter cleared after giving up"
414
- fi
415
- rm -rf "$work"
416
-
417
- work=$(mktemp -d)
418
- make_stub_bin "$work/bin"
419
- make_transcript "$work/transcript.jsonl"
420
- MX_STUB_EXIT=124 run_autosave "$work"
421
- if [[ -s "$work/state-line" ]]; then
422
- ok "auto-save: timeout (124) is terminal — watermark advances, no duplicate-minting retry"
423
- else
424
- nope "auto-save: timeout (124) is terminal" "state-line missing"
425
- fi
426
- assert_contains "$(cat "$work/stderr.txt")" "not retryable" \
427
- "auto-save: timeout diagnostic names it as not retryable"
428
- rm -rf "$work"
429
-
430
- # 9b. auto-save: same unwritable-counter handling.
431
- work=$(mktemp -d)
432
- make_stub_bin "$work/bin"
433
- make_transcript "$work/transcript.jsonl"
434
- mkdir -p "$work/state-line.fails"
435
- MX_STUB_EXIT=5 run_autosave "$work"
436
- if [[ -s "$work/state-line" ]]; then
437
- ok "auto-save: unwritable counter advances the watermark on the first failure"
438
- else
439
- nope "auto-save: unwritable counter advances the watermark" "state-line missing"
440
- fi
441
- assert_contains "$(cat "$work/stderr.txt")" "cannot persist the retry counter" \
442
- "auto-save: unwritable counter names the reason"
443
- MX_STUB_EXIT=5 run_autosave "$work"
444
- assert_eq "1" "$(count_mx_calls "$work")" \
445
- "auto-save: unwritable counter — run 2 attempts no create"
446
- rm -rf "$work"
447
-
448
- # 10. precompact emits exactly one diagnostic per trigger (it has no watermark;
449
- # one line per PreCompact event is inherent and bounded by the event).
450
- work=$(mktemp -d)
451
- make_stub_bin "$work/bin"
452
- make_transcript "$work/transcript.jsonl"
453
- mkdir -p "$work/home"
454
- ( cd "$work" && PATH="$work/bin:$REAL_PATH" HOME="$work/home" MX_STUB_EXIT=5 \
455
- bash "$PRECOMPACT_HOOK" \
456
- <<<"{\"transcript_path\":\"$work/transcript.jsonl\",\"session_id\":\"s1\",\"trigger\":\"auto\"}" \
457
- 2>"$work/stderr.txt" >/dev/null )
458
- assert_eq "1" "$(grep -c 'mx memories create failed' "$work/stderr.txt")" \
459
- "precompact: exactly one diagnostic per trigger"
460
- rm -rf "$work"
461
-
462
- echo ""
463
- echo "capture-hooks.test.sh: $PASS passed, $FAIL failed"
464
- [[ "$FAIL" -eq 0 ]] || exit 1
465
- exit 0