context-mode 1.0.151 → 1.0.153

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.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/mcp.json +5 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.openclaw-plugin/openclaw.plugin.json +16 -1
  6. package/.openclaw-plugin/package.json +1 -1
  7. package/README.md +89 -3
  8. package/build/adapters/claude-code/hooks.js +2 -2
  9. package/build/adapters/claude-code/index.js +14 -13
  10. package/build/adapters/client-map.js +3 -0
  11. package/build/adapters/detect.js +13 -1
  12. package/build/adapters/gemini-cli/hooks.d.ts +10 -0
  13. package/build/adapters/gemini-cli/hooks.js +12 -2
  14. package/build/adapters/gemini-cli/index.d.ts +21 -1
  15. package/build/adapters/gemini-cli/index.js +37 -1
  16. package/build/adapters/kimi/config.d.ts +8 -0
  17. package/build/adapters/kimi/config.js +8 -0
  18. package/build/adapters/kimi/hooks.d.ts +28 -0
  19. package/build/adapters/kimi/hooks.js +34 -0
  20. package/build/adapters/kimi/index.d.ts +66 -0
  21. package/build/adapters/kimi/index.js +537 -0
  22. package/build/adapters/kimi/paths.d.ts +1 -0
  23. package/build/adapters/kimi/paths.js +12 -0
  24. package/build/adapters/kiro/hooks.js +2 -2
  25. package/build/adapters/openclaw/plugin.d.ts +14 -13
  26. package/build/adapters/openclaw/plugin.js +140 -40
  27. package/build/adapters/opencode/plugin.js +4 -3
  28. package/build/adapters/opencode/zod3tov4.js +8 -8
  29. package/build/adapters/pi/extension.js +9 -24
  30. package/build/adapters/pi/mcp-bridge.js +37 -0
  31. package/build/adapters/qwen-code/index.js +7 -7
  32. package/build/adapters/types.d.ts +39 -2
  33. package/build/adapters/types.js +55 -2
  34. package/build/cli.js +433 -25
  35. package/build/executor.js +6 -3
  36. package/build/runtime.d.ts +81 -1
  37. package/build/runtime.js +195 -9
  38. package/build/search/ctx-search-schema.d.ts +90 -0
  39. package/build/search/ctx-search-schema.js +135 -0
  40. package/build/search/unified.d.ts +12 -0
  41. package/build/search/unified.js +17 -2
  42. package/build/server.d.ts +2 -1
  43. package/build/server.js +378 -97
  44. package/build/session/analytics.d.ts +36 -3
  45. package/build/session/analytics.js +88 -26
  46. package/build/session/db.d.ts +24 -0
  47. package/build/session/db.js +41 -0
  48. package/build/session/extract.js +30 -0
  49. package/build/session/snapshot.js +24 -0
  50. package/build/store.d.ts +12 -1
  51. package/build/store.js +72 -20
  52. package/build/types.d.ts +7 -0
  53. package/build/util/project-dir.d.ts +19 -16
  54. package/build/util/project-dir.js +80 -45
  55. package/cli.bundle.mjs +370 -319
  56. package/configs/kimi/hooks.json +54 -0
  57. package/configs/pi/AGENTS.md +3 -85
  58. package/hooks/cache-heal-utils.mjs +148 -0
  59. package/hooks/core/formatters.mjs +26 -0
  60. package/hooks/core/routing.mjs +9 -1
  61. package/hooks/core/stdin.mjs +74 -3
  62. package/hooks/core/tool-naming.mjs +1 -0
  63. package/hooks/heal-partial-install.mjs +712 -0
  64. package/hooks/kimi/platform.mjs +1 -0
  65. package/hooks/kimi/posttooluse.mjs +72 -0
  66. package/hooks/kimi/precompact.mjs +80 -0
  67. package/hooks/kimi/pretooluse.mjs +42 -0
  68. package/hooks/kimi/sessionend.mjs +61 -0
  69. package/hooks/kimi/sessionstart.mjs +113 -0
  70. package/hooks/kimi/stop.mjs +61 -0
  71. package/hooks/kimi/userpromptsubmit.mjs +90 -0
  72. package/hooks/normalize-hooks.mjs +66 -12
  73. package/hooks/routing-block.mjs +8 -2
  74. package/hooks/security.bundle.mjs +1 -1
  75. package/hooks/session-db.bundle.mjs +6 -4
  76. package/hooks/session-extract.bundle.mjs +2 -2
  77. package/hooks/session-helpers.mjs +93 -3
  78. package/hooks/session-snapshot.bundle.mjs +20 -19
  79. package/hooks/sessionstart.mjs +64 -0
  80. package/insight/server.mjs +15 -3
  81. package/openclaw.plugin.json +16 -1
  82. package/package.json +1 -1
  83. package/scripts/heal-installed-plugins.mjs +31 -10
  84. package/scripts/postinstall.mjs +10 -0
  85. package/server.bundle.mjs +206 -157
  86. package/skills/ctx-index/SKILL.md +46 -0
  87. package/skills/ctx-search/SKILL.md +35 -0
  88. package/start.mjs +84 -11
  89. package/build/cache-heal.d.ts +0 -48
  90. package/build/cache-heal.js +0 -150
  91. package/build/concurrency/runPool.d.ts +0 -36
  92. package/build/concurrency/runPool.js +0 -51
  93. package/build/openclaw/mcp-tools.d.ts +0 -54
  94. package/build/openclaw/mcp-tools.js +0 -198
  95. package/build/openclaw/workspace-router.d.ts +0 -29
  96. package/build/openclaw/workspace-router.js +0 -64
  97. package/build/openclaw-plugin.d.ts +0 -130
  98. package/build/openclaw-plugin.js +0 -626
  99. package/build/opencode-plugin.d.ts +0 -122
  100. package/build/opencode-plugin.js +0 -375
  101. package/build/pi-extension.d.ts +0 -14
  102. package/build/pi-extension.js +0 -451
  103. package/build/routing-block.d.ts +0 -8
  104. package/build/routing-block.js +0 -86
  105. package/build/tool-naming.d.ts +0 -4
  106. package/build/tool-naming.js +0 -24
package/build/cli.js CHANGED
@@ -16,15 +16,17 @@
16
16
  import * as p from "@clack/prompts";
17
17
  import color from "picocolors";
18
18
  import { execFileSync, execSync, execFile as nodeExecFile } from "node:child_process";
19
- import { readFileSync, cpSync, accessSync, existsSync, readdirSync, rmSync, closeSync, openSync, chmodSync, constants } from "node:fs";
19
+ import { readFileSync, cpSync, accessSync, existsSync, readdirSync, rmSync, closeSync, openSync, chmodSync, lstatSync, realpathSync, statSync, constants } from "node:fs";
20
20
  import { request as httpsRequest } from "node:https";
21
- import { resolve, dirname, join } from "node:path";
21
+ import { resolve, dirname, join, sep, basename, isAbsolute } from "node:path";
22
22
  import { tmpdir, devNull, homedir } from "node:os";
23
23
  import { fileURLToPath, pathToFileURL } from "node:url";
24
24
  import { detectRuntimes, getRuntimeSummary, hasBunRuntime, getAvailableLanguages, } from "./runtime.js";
25
25
  import { getHookScriptPaths } from "./util/hook-config.js";
26
26
  import { resolveClaudeConfigDir } from "./util/claude-config.js";
27
27
  import { ensureWritableStorageDir, formatStorageDirectoryError, resolveContentStorageDir, resolveSessionStorageDir, resolveStatsStorageDir, StorageDirectoryError, } from "./session/db.js";
28
+ import { ContentStore } from "./store.js";
29
+ import { readToolDenyPatterns, evaluateFilePath } from "./security.js";
28
30
  // v1.0.128 — Issue #559 sibling MCP kill helpers (see PR-559-560-FIX-DESIGN.md).
29
31
  import { discoverSiblingMcpPids, killSiblingMcpServers } from "./util/sibling-mcp.js";
30
32
  // v1.0.119 — Issue #523 Layer 5 heal: post-bump assertion on .claude-plugin/plugin.json
@@ -49,6 +51,7 @@ function browserOpenArgv(url, platform) {
49
51
  }
50
52
  // ── Adapter imports ──────────────────────────────────────
51
53
  import { detectPlatform, getAdapter } from "./adapters/detect.js";
54
+ import { isInProcessPluginPlatform } from "./adapters/types.js";
52
55
  /* -------------------------------------------------------
53
56
  * Hook dispatcher — `context-mode hook <platform> <event>`
54
57
  * ------------------------------------------------------- */
@@ -98,6 +101,15 @@ const HOOK_MAP = {
98
101
  precompact: "hooks/jetbrains-copilot/precompact.mjs",
99
102
  sessionstart: "hooks/jetbrains-copilot/sessionstart.mjs",
100
103
  },
104
+ "kimi": {
105
+ pretooluse: "hooks/kimi/pretooluse.mjs",
106
+ posttooluse: "hooks/kimi/posttooluse.mjs",
107
+ precompact: "hooks/kimi/precompact.mjs",
108
+ sessionstart: "hooks/kimi/sessionstart.mjs",
109
+ sessionend: "hooks/kimi/sessionend.mjs",
110
+ userpromptsubmit: "hooks/kimi/userpromptsubmit.mjs",
111
+ stop: "hooks/kimi/stop.mjs",
112
+ },
101
113
  "qwen-code": {
102
114
  pretooluse: "hooks/pretooluse.mjs",
103
115
  posttooluse: "hooks/posttooluse.mjs",
@@ -128,18 +140,35 @@ async function hookDispatch(platform, event) {
128
140
  /* -------------------------------------------------------
129
141
  * Entry point
130
142
  * ------------------------------------------------------- */
131
- const IN_PROCESS_PLUGIN_PLATFORMS = new Set(["opencode", "kilo"]);
132
- const isInProcessPluginPlatform = (p) => p ? IN_PROCESS_PLUGIN_PLATFORMS.has(p) : false;
133
143
  const args = process.argv.slice(2);
134
144
  function printHelp() {
135
145
  console.log([
136
146
  "Usage:",
137
147
  " context-mode Start MCP server (stdio)",
148
+ " context-mode index <path> Index a file or directory into the FTS5 knowledge base",
149
+ " context-mode search <query...> Search the current project's FTS5 knowledge base",
138
150
  " context-mode doctor Diagnose runtime issues, hooks, FTS5, version",
139
151
  " context-mode upgrade Fix hooks, permissions, and settings",
140
152
  " context-mode hook <platform> <event> Dispatch a configured hook script",
141
153
  " context-mode statusline Print Claude Code status line",
142
154
  "",
155
+ "Index options:",
156
+ " --source <label> Source label (default: project:<directory-name> or path)",
157
+ " --project <path> Project identity for the content DB (default: indexed dir or cwd)",
158
+ " --max-depth <n> Directory recursion depth (default: 5)",
159
+ " --max-files <n> Directory file cap (default: 200)",
160
+ " --ext <.ts,.md> Comma-separated extension allowlist",
161
+ " --include <glob> Directory include pattern (repeatable)",
162
+ " --exclude <glob> Directory exclude pattern (repeatable)",
163
+ " --no-gitignore Do not apply .gitignore during directory walks",
164
+ " --follow-symlinks Follow directory symlinks inside the root",
165
+ "",
166
+ "Search options:",
167
+ " --project <path> Project identity for the content DB (default: cwd)",
168
+ " --source <label> Filter to a source label (partial match)",
169
+ " --limit <n> Results to show (default: 3)",
170
+ " --type <code|prose> Filter by content type",
171
+ "",
143
172
  "Environment:",
144
173
  " CONTEXT_MODE_DIR=/absolute/path Override sessions/content storage root; empty is ignored, non-empty must be absolute",
145
174
  ].join("\n"));
@@ -147,6 +176,12 @@ function printHelp() {
147
176
  if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
148
177
  printHelp();
149
178
  }
179
+ else if (args[0] === "index") {
180
+ indexCommand(args.slice(1)).then((code) => process.exit(code));
181
+ }
182
+ else if (args[0] === "search") {
183
+ searchCommand(args.slice(1)).then((code) => process.exit(code));
184
+ }
150
185
  else if (args[0] === "doctor") {
151
186
  doctor().then((code) => process.exit(code));
152
187
  }
@@ -300,6 +335,206 @@ async function fetchLatestVersion() {
300
335
  function describeStorageSource(dir) {
301
336
  return dir.envVar ? dir.envVar : "adapter default";
302
337
  }
338
+ function parseFlags(argv) {
339
+ const positional = [];
340
+ const flags = {};
341
+ for (let i = 0; i < argv.length; i++) {
342
+ const arg = argv[i];
343
+ if (!arg.startsWith("--") || arg === "--") {
344
+ positional.push(arg);
345
+ continue;
346
+ }
347
+ const raw = arg.slice(2);
348
+ const eq = raw.indexOf("=");
349
+ const key = eq >= 0 ? raw.slice(0, eq) : raw;
350
+ const inlineValue = eq >= 0 ? raw.slice(eq + 1) : undefined;
351
+ const next = argv[i + 1];
352
+ const value = inlineValue !== undefined
353
+ ? inlineValue
354
+ : next && !next.startsWith("--")
355
+ ? (i++, next)
356
+ : true;
357
+ if (key === "include" || key === "exclude") {
358
+ const prev = flags[key];
359
+ flags[key] = Array.isArray(prev) ? [...prev, String(value)] : [String(value)];
360
+ }
361
+ else {
362
+ flags[key] = value;
363
+ }
364
+ }
365
+ return { positional, flags };
366
+ }
367
+ function stringFlag(flags, key) {
368
+ const v = flags[key];
369
+ if (typeof v === "string" && v.length > 0)
370
+ return v;
371
+ return undefined;
372
+ }
373
+ function boolFlag(flags, key) {
374
+ return flags[key] === true || flags[key] === "true";
375
+ }
376
+ function stringListFlag(flags, key) {
377
+ const v = flags[key];
378
+ if (Array.isArray(v))
379
+ return v.filter(Boolean);
380
+ if (typeof v === "string" && v.length > 0)
381
+ return [v];
382
+ return undefined;
383
+ }
384
+ function numberFlag(flags, key, opts = {}) {
385
+ const raw = stringFlag(flags, key);
386
+ if (!raw)
387
+ return undefined;
388
+ const n = Number(raw);
389
+ const min = opts.min ?? 1;
390
+ if (!Number.isInteger(n) || n < min)
391
+ throw new Error(`--${key} must be an integer >= ${min}`);
392
+ return n;
393
+ }
394
+ function extFlag(flags) {
395
+ const raw = stringFlag(flags, "ext") ?? stringFlag(flags, "extensions");
396
+ if (!raw)
397
+ return undefined;
398
+ const exts = raw
399
+ .split(",")
400
+ .map((x) => x.trim())
401
+ .filter(Boolean)
402
+ .map((x) => (x.startsWith(".") ? x : `.${x}`));
403
+ return exts.length > 0 ? exts : undefined;
404
+ }
405
+ function resolveCliProjectDir(projectFlag, fallback) {
406
+ if (projectFlag)
407
+ return resolve(projectFlag);
408
+ return resolve(fallback);
409
+ }
410
+ async function openCliContentStore(projectDir) {
411
+ const adapter = await getAdapter(detectPlatform().platform);
412
+ const contentStorage = resolveContentStorageDir(() => adapter.getSessionDir());
413
+ const contentDir = ensureWritableStorageDir(contentStorage);
414
+ const { resolveContentStorePath } = await import("./session/db.js");
415
+ const dbPath = resolveContentStorePath({ projectDir, contentDir });
416
+ return { store: new ContentStore(dbPath), dbPath, contentDir };
417
+ }
418
+ function defaultSourceForPath(absPath) {
419
+ try {
420
+ if (statSync(absPath).isDirectory())
421
+ return `project:${basename(absPath) || absPath}`;
422
+ }
423
+ catch { /* path errors are reported by the index command */ }
424
+ return absPath;
425
+ }
426
+ function assertReadAllowed(path, projectDir) {
427
+ const denyGlobs = readToolDenyPatterns("Read", projectDir);
428
+ const denied = evaluateFilePath(path, denyGlobs, process.platform === "win32", projectDir);
429
+ if (denied.denied) {
430
+ throw new Error(`Read denied by policy: ${path}`);
431
+ }
432
+ }
433
+ async function indexCommand(argv) {
434
+ try {
435
+ const parsed = parseFlags(argv);
436
+ const target = parsed.positional[0];
437
+ if (!target || target === "-h" || target === "--help") {
438
+ console.log("Usage: context-mode index <path> [--source label] [--project path] [--max-files n] [--max-depth n] [--ext .ts,.md]");
439
+ return target ? 0 : 1;
440
+ }
441
+ const absPath = isAbsolute(target) ? resolve(target) : resolve(process.cwd(), target);
442
+ if (!existsSync(absPath))
443
+ throw new Error(`Path does not exist: ${absPath}`);
444
+ const st = statSync(absPath);
445
+ const projectDir = resolveCliProjectDir(stringFlag(parsed.flags, "project"), st.isDirectory() ? absPath : dirname(absPath));
446
+ const source = stringFlag(parsed.flags, "source") ?? defaultSourceForPath(absPath);
447
+ const { store, dbPath } = await openCliContentStore(projectDir);
448
+ try {
449
+ assertReadAllowed(absPath, projectDir);
450
+ if (st.isDirectory()) {
451
+ const denyGlobs = readToolDenyPatterns("Read", projectDir);
452
+ const result = store.indexDirectory({
453
+ path: absPath,
454
+ source,
455
+ include: stringListFlag(parsed.flags, "include"),
456
+ exclude: stringListFlag(parsed.flags, "exclude"),
457
+ maxDepth: numberFlag(parsed.flags, "max-depth", { min: 0 }),
458
+ maxFiles: numberFlag(parsed.flags, "max-files"),
459
+ extensions: extFlag(parsed.flags),
460
+ respectGitignore: !boolFlag(parsed.flags, "no-gitignore"),
461
+ followSymlinks: boolFlag(parsed.flags, "follow-symlinks"),
462
+ perFileDeny: (filePath) => {
463
+ try {
464
+ return evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir).denied;
465
+ }
466
+ catch {
467
+ return false;
468
+ }
469
+ },
470
+ });
471
+ const cap = result.capped ? ` (cap reached at ${result.filesIndexed} files)` : "";
472
+ const denied = result.denied > 0 ? `; ${result.denied} denied` : "";
473
+ const failed = result.failed > 0 ? `; ${result.failed} failed` : "";
474
+ console.log(`Indexed ${result.filesIndexed} files (${result.totalChunks} sections) from ${absPath}${cap}${denied}${failed}`);
475
+ }
476
+ else {
477
+ const result = store.index({ path: absPath, source });
478
+ console.log(`Indexed ${result.totalChunks} sections (${result.codeChunks} with code) from ${absPath}`);
479
+ }
480
+ console.log(`Source: ${source}`);
481
+ console.log(`Project: ${projectDir}`);
482
+ console.log(`DB: ${dbPath}`);
483
+ return 0;
484
+ }
485
+ finally {
486
+ store.close();
487
+ }
488
+ }
489
+ catch (err) {
490
+ const message = err instanceof Error ? err.message : String(err);
491
+ console.error(`context-mode index: ${message}`);
492
+ return 1;
493
+ }
494
+ }
495
+ async function searchCommand(argv) {
496
+ try {
497
+ const parsed = parseFlags(argv);
498
+ const query = parsed.positional.join(" ").trim();
499
+ if (!query || query === "-h" || query === "--help") {
500
+ console.log("Usage: context-mode search <query...> [--source label] [--project path] [--limit n] [--type code|prose]");
501
+ return query ? 0 : 1;
502
+ }
503
+ const projectDir = resolveCliProjectDir(stringFlag(parsed.flags, "project"), process.cwd());
504
+ const { store, dbPath } = await openCliContentStore(projectDir);
505
+ try {
506
+ const limit = numberFlag(parsed.flags, "limit") ?? 3;
507
+ const type = stringFlag(parsed.flags, "type");
508
+ if (type && type !== "code" && type !== "prose")
509
+ throw new Error("--type must be code or prose");
510
+ const results = store.searchWithFallback(query, limit, stringFlag(parsed.flags, "source"), type);
511
+ if (results.length === 0) {
512
+ console.log(`No matches for: ${query}`);
513
+ console.log(`Project: ${projectDir}`);
514
+ console.log(`DB: ${dbPath}`);
515
+ return 0;
516
+ }
517
+ for (const [i, r] of results.entries()) {
518
+ const content = r.content.replace(/\s+/g, " ").trim();
519
+ const snippet = content.length > 500 ? `${content.slice(0, 500)}...` : content;
520
+ console.log(`## ${i + 1}. ${r.title}`);
521
+ console.log(`Source: ${r.source}`);
522
+ console.log(`Type: ${r.contentType}`);
523
+ console.log(snippet);
524
+ console.log("");
525
+ }
526
+ return 0;
527
+ }
528
+ finally {
529
+ store.close();
530
+ }
531
+ }
532
+ catch (err) {
533
+ const message = err instanceof Error ? err.message : String(err);
534
+ console.error(`context-mode search: ${message}`);
535
+ return 1;
536
+ }
537
+ }
303
538
  function logStorageDir(dir) {
304
539
  try {
305
540
  ensureWritableStorageDir(dir);
@@ -1019,12 +1254,55 @@ async function upgrade(opts) {
1019
1254
  ...(clonedPkg.files || []),
1020
1255
  "src", "package.json",
1021
1256
  ];
1257
+ // Supply-chain containment on items[]. A compromised upstream tag
1258
+ // shipping files: ["../../.ssh/authorized_keys"] or an absolute
1259
+ // path would, without a guard, hand rmSync+cpSync an arbitrary
1260
+ // destination under the user's UID. resolve(P, "/abs") discards P,
1261
+ // so the absolute-path variant escapes too. Reject items whose
1262
+ // resolved path escapes either srcDir or pluginRoot. Mirrors the
1263
+ // pattern hooks/heal-partial-install.mjs already uses for its own
1264
+ // files[] expansion (PR #699).
1265
+ //
1266
+ // Also refuse to copy any symlink encountered anywhere under a
1267
+ // source item. cpSync's default is to preserve source symlinks as
1268
+ // destination symlinks; a compromised upstream tag committing a
1269
+ // symlink to /etc inside src/ would plant that link in pluginRoot,
1270
+ // and the next Claude Code session that loads pluginRoot/src/*
1271
+ // would dereference through to the attacker target. Filtering at
1272
+ // copy time keeps pluginRoot symlink-free regardless of what the
1273
+ // clone shipped.
1274
+ const pluginRootWithSep = resolve(pluginRoot) + sep;
1275
+ const srcDirWithSep = resolve(srcDir) + sep;
1276
+ const refuseSymlinks = (src) => {
1277
+ try {
1278
+ return !lstatSync(src).isSymbolicLink();
1279
+ }
1280
+ catch {
1281
+ return false;
1282
+ }
1283
+ };
1022
1284
  for (const item of items) {
1285
+ const from = resolve(srcDir, item);
1286
+ const to = resolve(pluginRoot, item);
1287
+ if (!(to + sep).startsWith(pluginRootWithSep))
1288
+ continue;
1289
+ if (!(from + sep).startsWith(srcDirWithSep))
1290
+ continue;
1291
+ if (!refuseSymlinks(from))
1292
+ continue;
1293
+ // Existence-check the source BEFORE the rm so a `files[]` entry that
1294
+ // doesn't exist in srcDir can never delete-without-replace at
1295
+ // pluginRoot. The catch-all below swallows cpSync failures too, and
1296
+ // a swallowed cp after a successful rm is exactly how a partial
1297
+ // install lands silently. Mirrors the safe pattern in
1298
+ // server.ts's inline-fallback upgrade path (PR #699).
1299
+ if (!existsSync(from))
1300
+ continue;
1023
1301
  try {
1024
- rmSync(resolve(pluginRoot, item), { recursive: true, force: true });
1025
- cpSync(resolve(srcDir, item), resolve(pluginRoot, item), { recursive: true });
1302
+ rmSync(to, { recursive: true, force: true });
1303
+ cpSync(from, to, { recursive: true, filter: refuseSymlinks });
1026
1304
  }
1027
- catch { /* some files may not exist in source */ }
1305
+ catch { /* best effort, next /ctx-upgrade retries */ }
1028
1306
  }
1029
1307
  // Issue #609 — DO NOT write `.mcp.json` into the plugin cache dir.
1030
1308
  //
@@ -1044,24 +1322,78 @@ async function upgrade(opts) {
1044
1322
  // historically was to be a write-time poison vector. Don't write it.
1045
1323
  // The post-bump cache-sweep below removes any pre-existing copies so
1046
1324
  // the previous-version-carry vector cannot replay.
1047
- // Normalize hooks.json + plugin.json against the REAL pluginRoot now that
1048
- // files have been copied. Two reasons:
1049
- // 1. If a prior buggy postinstall (or any future regression) baked the
1050
- // tmpdir path into hooks.json, this rewrites it to pluginRoot before
1051
- // the next hook fires.
1052
- // 2. Closes the same gap #414 closed for fresh installs — the first
1053
- // hook fire after upgrade now works without waiting for MCP boot.
1325
+ // Issue #711 + #414 split: normalize hooks.json (only) here.
1326
+ //
1327
+ // - plugin.json must NOT be normalized during /ctx-upgrade Claude
1328
+ // Code carries it forward into new versioned cache dirs on
1329
+ // auto-update, so baked absolute paths go stale (#711).
1330
+ // - hooks/hooks.json MUST be normalized during /ctx-upgrade on
1331
+ // Windows + Git Bash Claude Code fires SessionStart / PreToolUse
1332
+ // BEFORE the MCP server boots, so the unresolved
1333
+ // `${CLAUDE_PLUGIN_ROOT}` placeholder yields MODULE_NOT_FOUND for
1334
+ // the first hook fire after upgrade (#414, originally wired in
1335
+ // 13d1342 / #528).
1336
+ //
1337
+ // The narrow `normalizeHooksJsonOnly` helper preserves both invariants.
1338
+ // start.mjs continues to call the full `normalizeHooksOnStartup` at the
1339
+ // next MCP boot to re-heal plugin.json against the live __dirname.
1054
1340
  try {
1341
+ // #738: pass the resolved Bun ≥1.0 path so /ctx-upgrade's hooks.json
1342
+ // rewrite gains the same cold-start win as the boot-time rewrite.
1343
+ // Probe failures fall through to nodePath default.
1344
+ let jsRuntimePath;
1345
+ try {
1346
+ const { resolveHookRuntime } = await import("./runtime.js");
1347
+ const r = resolveHookRuntime();
1348
+ if (r.isBun)
1349
+ jsRuntimePath = r.path;
1350
+ }
1351
+ catch { /* best effort */ }
1055
1352
  const mod =
1056
1353
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1057
1354
  (await import("../hooks/normalize-hooks.mjs"));
1058
- mod.normalizeHooksOnStartup({
1355
+ mod.normalizeHooksJsonOnly({
1059
1356
  pluginRoot,
1060
1357
  nodePath: process.execPath,
1358
+ jsRuntimePath,
1061
1359
  platform: process.platform,
1062
1360
  });
1063
1361
  }
1064
1362
  catch { /* best effort — never block upgrade */ }
1363
+ // Issue #710 — Layer 1: rewrite stale shell-snapshot PATH entries.
1364
+ //
1365
+ // Claude Code's per-session shell snapshot
1366
+ // (~/.claude/shell-snapshots/snapshot-*.sh, baked at session boot —
1367
+ // refs/platforms/claude-code/src/utils/bash/ShellSnapshot.ts:269-336)
1368
+ // is `source`d before every Bash tool call. It contains an
1369
+ // `export PATH='…'` line including the context-mode `bin/` for the
1370
+ // version active at session start. /ctx-upgrade deletes the old
1371
+ // cache dir mid-session — the snapshot still points at it, so every
1372
+ // Bash call fails with "Plugin directory does not exist" until the
1373
+ // session restarts. Layer 1 fixes the active session immediately;
1374
+ // Layer 2 (sessionstart.mjs) heals any session that started before
1375
+ // /ctx-upgrade ran.
1376
+ //
1377
+ // claude-code only — no other adapter uses shell-snapshots. Skip
1378
+ // when running under a non-claude-code adapter (Codex/Cursor/Gemini
1379
+ // etc. spawn Bash differently and have no `~/.claude/shell-snapshots`
1380
+ // tree). Best-effort, idempotent, never throws.
1381
+ try {
1382
+ if (detection.platform === "claude-code") {
1383
+ const { rewriteShellSnapshots } = await import(
1384
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1385
+ "../hooks/cache-heal-utils.mjs");
1386
+ const snapshotsDir = resolve(resolveClaudeConfigDir(), "shell-snapshots");
1387
+ const result = rewriteShellSnapshots({
1388
+ snapshotsDir,
1389
+ currentVersion: newVersion,
1390
+ });
1391
+ if (result.rewritten.length > 0) {
1392
+ p.log.info(color.dim(` Healed ${result.rewritten.length} stale shell snapshot(s) — Bash tool calls in the active session will pick up v${newVersion} immediately`));
1393
+ }
1394
+ }
1395
+ }
1396
+ catch { /* best effort — never block upgrade */ }
1065
1397
  s.stop(color.green(`Updated in-place to v${newVersion}`));
1066
1398
  // v1.0.114 hotfix — pre-flight: verify the in-place copy actually
1067
1399
  // wrote a plugin.json carrying newVersion BEFORE we tell the
@@ -1292,19 +1624,61 @@ async function upgrade(opts) {
1292
1624
  // Issue #460 round-3: honor $CLAUDE_CONFIG_DIR so the registry lookup
1293
1625
  // tracks relocated CC config trees.
1294
1626
  try {
1295
- const registryPath = resolve(resolveClaudeConfigDir(), "plugins", "installed_plugins.json");
1627
+ const claudeRoot = resolveClaudeConfigDir();
1628
+ const registryPath = resolve(claudeRoot, "plugins", "installed_plugins.json");
1296
1629
  if (existsSync(registryPath)) {
1630
+ // The registry's installPath fields are written by Claude Code under
1631
+ // <claudeRoot>/plugins/cache/<marketplace>/<plugin>/<version>. Any other
1632
+ // shape means the registry has been tampered with by a co-resident
1633
+ // plugin, a malicious postinstall script, or another local actor.
1634
+ // Without containment, cpSync would happily recursive-write the in-repo
1635
+ // skills/ tree to /etc/skills, ~/.ssh/skills, or wherever the attacker
1636
+ // pointed. server.ts:790 (healCacheMidSession) already gates the same
1637
+ // field this way; the symmetric guard belongs here too.
1638
+ //
1639
+ // The lexical resolve+startsWith check rejects ".."-escapes and
1640
+ // absolute paths outside cacheRoot, but path.resolve doesn't
1641
+ // dereference symlinks. A same-uid actor who can plant a symlink
1642
+ // AT <cacheRoot>/<owner>/<plugin>/<version> targeting an attacker
1643
+ // dir gets past the lexical guard, then cpSync follows the link at
1644
+ // FS-write time. Re-check via realpathSync so a planted symlink
1645
+ // anchor fails the gate.
1646
+ const cacheRoot = resolve(claudeRoot, "plugins", "cache");
1647
+ let cacheRootCanon;
1648
+ try {
1649
+ cacheRootCanon = realpathSync(cacheRoot);
1650
+ }
1651
+ catch {
1652
+ cacheRootCanon = cacheRoot;
1653
+ }
1654
+ const cacheRootWithSep = cacheRootCanon + sep;
1297
1655
  const registry = JSON.parse(readFileSync(registryPath, "utf-8"));
1298
1656
  const entries = registry?.plugins?.["context-mode@context-mode"];
1299
1657
  if (Array.isArray(entries)) {
1300
1658
  for (const entry of entries) {
1301
- const installPath = entry.installPath;
1302
- if (installPath && installPath !== pluginRoot && existsSync(installPath)) {
1303
- const srcSkills = resolve(srcDir, "skills");
1304
- if (existsSync(srcSkills)) {
1305
- cpSync(srcSkills, resolve(installPath, "skills"), { recursive: true });
1306
- changes.push(`Synced skills to active install path`);
1307
- }
1659
+ const installPath = entry?.installPath;
1660
+ if (typeof installPath !== "string" || !installPath)
1661
+ continue;
1662
+ if (installPath === pluginRoot)
1663
+ continue;
1664
+ const resolvedInstallPath = resolve(installPath);
1665
+ if (!(resolvedInstallPath + sep).startsWith(cacheRootWithSep))
1666
+ continue;
1667
+ if (!existsSync(resolvedInstallPath))
1668
+ continue;
1669
+ let realInstallPath;
1670
+ try {
1671
+ realInstallPath = realpathSync(resolvedInstallPath);
1672
+ }
1673
+ catch {
1674
+ continue;
1675
+ }
1676
+ if (!(realInstallPath + sep).startsWith(cacheRootWithSep))
1677
+ continue;
1678
+ const srcSkills = resolve(srcDir, "skills");
1679
+ if (existsSync(srcSkills)) {
1680
+ cpSync(srcSkills, resolve(realInstallPath, "skills"), { recursive: true });
1681
+ changes.push(`Synced skills to active install path`);
1308
1682
  }
1309
1683
  }
1310
1684
  }
@@ -1452,14 +1826,48 @@ function statuslineForward() {
1452
1826
  try {
1453
1827
  const registryPath = resolve(claudeRoot, "plugins", "installed_plugins.json");
1454
1828
  if (existsSync(registryPath)) {
1829
+ // Same trust boundary as the cpSync site in upgrade() and as
1830
+ // server.ts:790's healCacheMidSession: only honor installPath values
1831
+ // that resolve under <claudeRoot>/plugins/cache. A stray /etc or
1832
+ // ~/.ssh entry written by another local actor must not become the
1833
+ // script the statusline forwarder imports, since statusline re-fires
1834
+ // several times per second and would hand the attacker durable RCE
1835
+ // on the user's behalf.
1836
+ //
1837
+ // path.resolve is purely lexical, so a same-uid actor who can plant
1838
+ // a symlink at <cacheRoot>/<owner>/<plugin>/<version> targeting an
1839
+ // attacker dir would pass the lexical gate. Re-check via
1840
+ // realpathSync so the dynamic-import target's actual on-disk
1841
+ // location also stays under cacheRoot.
1842
+ const cacheRoot = resolve(claudeRoot, "plugins", "cache");
1843
+ let cacheRootCanon;
1844
+ try {
1845
+ cacheRootCanon = realpathSync(cacheRoot);
1846
+ }
1847
+ catch {
1848
+ cacheRootCanon = cacheRoot;
1849
+ }
1850
+ const cacheRootWithSep = cacheRootCanon + sep;
1455
1851
  const registry = JSON.parse(readFileSync(registryPath, "utf-8"));
1456
1852
  const entries = registry?.plugins?.["context-mode@context-mode"];
1457
1853
  if (Array.isArray(entries)) {
1458
1854
  for (const entry of entries) {
1459
1855
  const installPath = entry?.installPath;
1460
- if (typeof installPath === "string" && installPath) {
1461
- candidates.push(resolve(installPath, "bin", "statusline.mjs"));
1856
+ if (typeof installPath !== "string" || !installPath)
1857
+ continue;
1858
+ const resolvedInstallPath = resolve(installPath);
1859
+ if (!(resolvedInstallPath + sep).startsWith(cacheRootWithSep))
1860
+ continue;
1861
+ let realInstallPath;
1862
+ try {
1863
+ realInstallPath = realpathSync(resolvedInstallPath);
1864
+ }
1865
+ catch {
1866
+ continue;
1462
1867
  }
1868
+ if (!(realInstallPath + sep).startsWith(cacheRootWithSep))
1869
+ continue;
1870
+ candidates.push(resolve(realInstallPath, "bin", "statusline.mjs"));
1463
1871
  }
1464
1872
  }
1465
1873
  }
package/build/executor.js CHANGED
@@ -29,9 +29,12 @@ const SCRIPT_EXT = {
29
29
  export function buildScriptFilename(language, platform, shellPath) {
30
30
  if (platform === "win32" && language === "shell") {
31
31
  const shellName = shellPath?.toLowerCase() ?? "";
32
- return shellName.includes("powershell") || shellName.includes("pwsh")
33
- ? "script.ps1"
34
- : "script";
32
+ if (shellName.includes("powershell") || shellName.includes("pwsh"))
33
+ return "script.ps1";
34
+ const shellBase = shellName.split(/[\\/]/).pop() ?? shellName;
35
+ if (shellBase === "cmd" || shellBase === "cmd.exe")
36
+ return "script.cmd";
37
+ return "script";
35
38
  }
36
39
  return `script.${SCRIPT_EXT[language]}`;
37
40
  }