@mxalbert/context-mode 2.0.3 → 2.0.4

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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "2.0.3"
9
+ "version": "2.0.4"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "context-mode",
14
14
  "source": "./",
15
15
  "description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
16
- "version": "2.0.3",
16
+ "version": "2.0.4",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "2.0.3",
6
+ "version": "2.0.4",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mxalbert/context-mode",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
package/build/cli.js CHANGED
@@ -452,7 +452,7 @@ function defaultSourceForPath(absPath) {
452
452
  }
453
453
  function assertReadAllowed(path, projectDir) {
454
454
  const denyGlobs = readToolDenyPatterns("Read", projectDir);
455
- const denied = evaluateFilePath(path, denyGlobs, process.platform === "win32", projectDir);
455
+ const denied = evaluateFilePath(path, denyGlobs, undefined, projectDir);
456
456
  if (denied.denied) {
457
457
  throw new Error(`Read denied by policy: ${path}`);
458
458
  }
@@ -488,7 +488,7 @@ async function indexCommand(argv) {
488
488
  followSymlinks: boolFlag(parsed.flags, "follow-symlinks"),
489
489
  perFileDeny: (filePath) => {
490
490
  try {
491
- return evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir).denied;
491
+ return evaluateFilePath(filePath, denyGlobs, undefined, projectDir).denied;
492
492
  }
493
493
  catch {
494
494
  return false;
@@ -87,6 +87,18 @@ export declare function readToolDenyPatterns(toolName: string, projectDir?: stri
87
87
  * needs an out-of-project read expresses it once, in the host config, e.g.
88
88
  * `"permissions": { "allow": ["Read(/var/log/**)"] }`, and both the host and
89
89
  * context-mode honor it.
90
+ *
91
+ * Settings-source anchor (`/{path}`): per Claude Code's documented permission
92
+ * semantics, a rule glob with a SINGLE leading slash is NOT rooted at the
93
+ * filesystem root — it is anchored at the directory of the settings file that
94
+ * declared it. Project/local settings (`.claude/settings.json`,
95
+ * `.claude/settings.local.json`) therefore anchor at the primary working
96
+ * directory (`projectDir`), while user settings (`~/.claude/settings.json`),
97
+ * adapter-global settings, and a custom `--settings <file>` anchor at that
98
+ * settings file's own directory. Each such glob is emitted as a settings-source
99
+ * RESOLVED twin in addition to the literal glob — the change is additive: no
100
+ * existing literal match is removed, so the README's `Read(/var/log/**)`
101
+ * filesystem-root escape hatch keeps working as a literal absolute rule.
90
102
  */
91
103
  export declare function readToolPermissionPatterns(toolName: string, kind: "deny" | "allow", projectDir?: string, globalSettingsPath?: string): string[][];
92
104
  interface CommandDecision {
@@ -123,16 +135,36 @@ export declare function evaluateCommandDenyOnly(command: string, policies: Secur
123
135
  * Normalizes backslashes to forward slashes before matching so that
124
136
  * Windows paths work with Unix-style glob patterns.
125
137
  *
126
- * When `projectRoot` is supplied, the path is also matched in its
127
- * fully-resolved absolute form **and** when the file exists — in
128
- * its canonical form (`fs.realpathSync`). This prevents two classes
129
- * of bypass:
130
- *
131
- * 1. `..` traversal: a relative path like `../../.ssh/id_rsa` no
132
- * longer evades absolute-path deny rules.
133
- * 2. Symlink escape: a project-local path whose realpath points
134
- * outside the project (e.g. `safe.log -> ~/.ssh/id_rsa`) no
135
- * longer evades absolute-path deny rules.
138
+ * Rule and file path anchors are expanded before matching:
139
+ * - A leading `~` / `~/` in EITHER the rule glob or the file path is
140
+ * expanded to the user's home directory (Claude Code's `~/path`
141
+ * anchor). `~user/...` is left untouched.
142
+ * - A rule glob beginning with `//` is treated as "absolute from the
143
+ * filesystem root" and has the leading double slash collapsed to one,
144
+ * matching the absolute candidates this module produces. The literal
145
+ * double-slash form is preserved as an additional variant so Windows UNC
146
+ * rules (e.g. `//server/share/**`) still match UNC candidates, which
147
+ * normalize to `//server/share/...`.
148
+ *
149
+ * Symlink resolution is mirrored on both sides. For each rule glob the
150
+ * matcher tests its literal variant AND a best-effort `realpathSync`'d
151
+ * variant (only the static prefix, before the first glob segment, is
152
+ * canonicalized). Against those it tests every file candidate: the raw
153
+ * input, the tilde-expanded input, the path resolved against `projectRoot`
154
+ * (or `process.cwd()` when no project root is supplied), and — when the
155
+ * file exists — its canonical form. A deny match therefore fires if ANY
156
+ * literal/canonical rule variant matches ANY literal/lexical/canonical file
157
+ * candidate, closing the CVE-2025-59829 symlink deny-bypass class.
158
+ *
159
+ * Anchoring the resolved candidate to `projectRoot` (falling back to cwd)
160
+ * means absolute deny rules still match relative `..` traversal even when
161
+ * the caller does not know the project root.
162
+ *
163
+ * Conversely, a RELATIVE rule glob (no leading `/`, `//`, or `~`) is anchored
164
+ * to the current directory at match time — `projectRoot` when supplied, else
165
+ * `process.cwd()` — per the host's "relative to current directory" semantics.
166
+ * The literal glob is kept as well, so both a relative access path and an
167
+ * absolute one are matched.
136
168
  *
137
169
  * realpath is best-effort: if the file does not exist yet (ENOENT)
138
170
  * or the syscall fails for any reason, the lexical resolved form is
@@ -163,6 +195,8 @@ export declare function evaluateFilePath(filePath: string, denyGlobs: string[][]
163
195
  *
164
196
  * A path equal to the project root itself counts as inside. Comparison is
165
197
  * case-insensitive on Windows/macOS to match those filesystems' semantics.
198
+ * A leading `~` in `filePath` is expanded to the user's home directory for
199
+ * consistency with `evaluateFilePath`.
166
200
  *
167
201
  * Returns `true` when `projectRoot` is falsy (no boundary to enforce) so the
168
202
  * caller's fail-open posture is preserved when the root cannot be resolved.
package/build/security.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, realpathSync } from "node:fs";
2
- import { relative, resolve, sep } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
4
  import { resolveAdapterGlobalSettingsPaths } from "./util/claude-config.js";
4
5
  // ==============================================================================
5
6
  // Pattern Parsing
@@ -380,10 +381,22 @@ export function readToolDenyPatterns(toolName, projectDir, globalSettingsPath) {
380
381
  * needs an out-of-project read expresses it once, in the host config, e.g.
381
382
  * `"permissions": { "allow": ["Read(/var/log/**)"] }`, and both the host and
382
383
  * context-mode honor it.
384
+ *
385
+ * Settings-source anchor (`/{path}`): per Claude Code's documented permission
386
+ * semantics, a rule glob with a SINGLE leading slash is NOT rooted at the
387
+ * filesystem root — it is anchored at the directory of the settings file that
388
+ * declared it. Project/local settings (`.claude/settings.json`,
389
+ * `.claude/settings.local.json`) therefore anchor at the primary working
390
+ * directory (`projectDir`), while user settings (`~/.claude/settings.json`),
391
+ * adapter-global settings, and a custom `--settings <file>` anchor at that
392
+ * settings file's own directory. Each such glob is emitted as a settings-source
393
+ * RESOLVED twin in addition to the literal glob — the change is additive: no
394
+ * existing literal match is removed, so the README's `Read(/var/log/**)`
395
+ * filesystem-root escape hatch keeps working as a literal absolute rule.
383
396
  */
384
397
  export function readToolPermissionPatterns(toolName, kind, projectDir, globalSettingsPath) {
385
398
  const result = [];
386
- const extractGlobs = (path) => {
399
+ const extractGlobs = (path, baseDir) => {
387
400
  let raw;
388
401
  try {
389
402
  raw = readFileSync(path, "utf-8");
@@ -408,15 +421,22 @@ export function readToolPermissionPatterns(toolName, kind, projectDir, globalSet
408
421
  const tp = parseToolPattern(entry);
409
422
  if (tp && tp.tool === toolName) {
410
423
  globs.push(tp.glob);
424
+ // `/{path}` settings-source anchor: emit the settings-file-relative
425
+ // resolved twin alongside the literal glob (additive — the literal
426
+ // stays so filesystem-root rules like `Read(/var/log/**)` keep
427
+ // working). `//path` is the filesystem-root anchor and gets no twin.
428
+ if (baseDir && tp.glob.startsWith("/") && !tp.glob.startsWith("//")) {
429
+ globs.push(resolve(baseDir, "." + tp.glob));
430
+ }
411
431
  }
412
432
  }
413
433
  return globs;
414
434
  };
415
435
  if (projectDir) {
416
- const localGlobs = extractGlobs(resolve(projectDir, ".claude", "settings.local.json"));
436
+ const localGlobs = extractGlobs(resolve(projectDir, ".claude", "settings.local.json"), projectDir);
417
437
  if (localGlobs !== null)
418
438
  result.push(localGlobs);
419
- const sharedGlobs = extractGlobs(resolve(projectDir, ".claude", "settings.json"));
439
+ const sharedGlobs = extractGlobs(resolve(projectDir, ".claude", "settings.json"), projectDir);
420
440
  if (sharedGlobs !== null)
421
441
  result.push(sharedGlobs);
422
442
  }
@@ -428,7 +448,7 @@ export function readToolPermissionPatterns(toolName, kind, projectDir, globalSet
428
448
  ? [globalSettingsPath]
429
449
  : resolveAdapterGlobalSettingsPaths();
430
450
  for (const globalPath of globalPaths) {
431
- const globalGlobs = extractGlobs(globalPath);
451
+ const globalGlobs = extractGlobs(globalPath, dirname(globalPath));
432
452
  if (globalGlobs !== null)
433
453
  result.push(globalGlobs);
434
454
  }
@@ -510,22 +530,88 @@ export function evaluateCommandDenyOnly(command, policies, caseInsensitive = pro
510
530
  // ==============================================================================
511
531
  // File Path Evaluation
512
532
  // ==============================================================================
533
+ /**
534
+ * Expand a leading `~` / `~/` (or `~\` on Windows) to the user's home
535
+ * directory, mirroring Claude Code's documented `~/path` rule anchor.
536
+ * `~user/...` is intentionally left unchanged (needs a passwd lookup that is
537
+ * not portable). Returns the input unchanged when there is nothing to expand.
538
+ */
539
+ function expandHomeTilde(path) {
540
+ if (path === "~")
541
+ return homedir();
542
+ if (path.startsWith("~/") || path.startsWith("~\\"))
543
+ return homedir() + path.slice(1);
544
+ return path;
545
+ }
546
+ /**
547
+ * Claude Code's `//path` rule anchor means "absolute from the filesystem
548
+ * root". Collapse the leading double slash to one so the pattern can match
549
+ * the absolute candidates this module produces (`path.resolve` never emits a
550
+ * leading `//`). Patterns without the anchor are returned unchanged.
551
+ */
552
+ function normalizeRuleAnchor(glob) {
553
+ return glob.startsWith("//") ? glob.slice(1) : glob;
554
+ }
555
+ /**
556
+ * Best-effort symlink-resolved twin of a permission glob. Only the longest
557
+ * static prefix (path segments before the first `*`/`?` segment) is passed
558
+ * through `realpathSync`; glob segments are re-appended untouched. Returns
559
+ * null when there is no static prefix or it does not exist, so callers can
560
+ * simply fall back to the literal pattern.
561
+ */
562
+ function canonicalizeGlob(glob) {
563
+ const segments = glob.split(/[\\/]/);
564
+ let cut = segments.findIndex((s) => s.includes("*") || s.includes("?"));
565
+ if (cut === -1)
566
+ cut = segments.length;
567
+ if (cut === 0)
568
+ return null;
569
+ const prefix = segments.slice(0, cut).join(sep);
570
+ if (prefix.length === 0)
571
+ return null;
572
+ try {
573
+ return [realpathSync(prefix), ...segments.slice(cut)].join(sep);
574
+ }
575
+ catch {
576
+ return null;
577
+ }
578
+ }
513
579
  /**
514
580
  * Check if a file path should be denied based on deny globs.
515
581
  *
516
582
  * Normalizes backslashes to forward slashes before matching so that
517
583
  * Windows paths work with Unix-style glob patterns.
518
584
  *
519
- * When `projectRoot` is supplied, the path is also matched in its
520
- * fully-resolved absolute form **and** when the file exists — in
521
- * its canonical form (`fs.realpathSync`). This prevents two classes
522
- * of bypass:
585
+ * Rule and file path anchors are expanded before matching:
586
+ * - A leading `~` / `~/` in EITHER the rule glob or the file path is
587
+ * expanded to the user's home directory (Claude Code's `~/path`
588
+ * anchor). `~user/...` is left untouched.
589
+ * - A rule glob beginning with `//` is treated as "absolute from the
590
+ * filesystem root" and has the leading double slash collapsed to one,
591
+ * matching the absolute candidates this module produces. The literal
592
+ * double-slash form is preserved as an additional variant so Windows UNC
593
+ * rules (e.g. `//server/share/**`) still match UNC candidates, which
594
+ * normalize to `//server/share/...`.
595
+ *
596
+ * Symlink resolution is mirrored on both sides. For each rule glob the
597
+ * matcher tests its literal variant AND a best-effort `realpathSync`'d
598
+ * variant (only the static prefix, before the first glob segment, is
599
+ * canonicalized). Against those it tests every file candidate: the raw
600
+ * input, the tilde-expanded input, the path resolved against `projectRoot`
601
+ * (or `process.cwd()` when no project root is supplied), and — when the
602
+ * file exists — its canonical form. A deny match therefore fires if ANY
603
+ * literal/canonical rule variant matches ANY literal/lexical/canonical file
604
+ * candidate, closing the CVE-2025-59829 symlink deny-bypass class.
605
+ *
606
+ * Anchoring the resolved candidate to `projectRoot` (falling back to cwd)
607
+ * means absolute deny rules still match relative `..` traversal even when
608
+ * the caller does not know the project root.
523
609
  *
524
- * 1. `..` traversal: a relative path like `../../.ssh/id_rsa` no
525
- * longer evades absolute-path deny rules.
526
- * 2. Symlink escape: a project-local path whose realpath points
527
- * outside the project (e.g. `safe.log -> ~/.ssh/id_rsa`) no
528
- * longer evades absolute-path deny rules.
610
+ * Conversely, a RELATIVE rule glob (no leading `/`, `//`, or `~`) is anchored
611
+ * to the current directory at match time — `projectRoot` when supplied, else
612
+ * `process.cwd()` per the host's "relative to current directory" semantics.
613
+ * The literal glob is kept as well, so both a relative access path and an
614
+ * absolute one are matched.
529
615
  *
530
616
  * realpath is best-effort: if the file does not exist yet (ENOENT)
531
617
  * or the syscall fails for any reason, the lexical resolved form is
@@ -534,32 +620,59 @@ export function evaluateCommandDenyOnly(command, policies, caseInsensitive = pro
534
620
  */
535
621
  export function evaluateFilePath(filePath, denyGlobs, caseInsensitive = process.platform === "win32" || process.platform === "darwin", projectRoot) {
536
622
  const toForward = (path) => path.replace(/\\/g, "/");
537
- // Match against the raw input, the lexically-resolved absolute path,
538
- // and the canonical (symlink-resolved) path when the file exists.
539
- // Deduplicated so absolute inputs and paths that don't cross symlinks
540
- // don't pay the matching cost multiple times.
623
+ // File-side candidates: raw, tilde-expanded, anchored-resolved, canonical.
541
624
  const candidates = new Set();
542
625
  candidates.add(toForward(filePath));
543
- if (projectRoot) {
544
- const lexical = resolve(projectRoot, filePath);
545
- candidates.add(toForward(lexical));
546
- try {
547
- candidates.add(toForward(realpathSync(lexical)));
548
- }
549
- catch {
550
- // File does not exist yet, or realpath failed — rely on lexical form.
551
- }
626
+ const expandedFilePath = expandHomeTilde(filePath);
627
+ if (expandedFilePath !== filePath) {
628
+ candidates.add(toForward(expandedFilePath));
629
+ }
630
+ const lexical = resolve(projectRoot ?? process.cwd(), expandedFilePath);
631
+ candidates.add(toForward(lexical));
632
+ try {
633
+ candidates.add(toForward(realpathSync(lexical)));
634
+ }
635
+ catch {
636
+ // File does not exist yet, or realpath failed — rely on lexical form.
552
637
  }
553
638
  for (const globs of denyGlobs) {
554
639
  for (const glob of globs) {
640
+ // Rule-side variants: the literal glob (`//` intact, so Windows UNC
641
+ // rules still match UNC candidates), its `//`-collapsed root-absolute
642
+ // anchor form, the `~`-expanded form of each, and a best-effort
643
+ // symlink-resolved twin of every variant. Matching all of them against
644
+ // every candidate keeps both sides symmetric.
645
+ const anchors = new Set();
646
+ anchors.add(glob);
647
+ anchors.add(normalizeRuleAnchor(glob));
648
+ const expandedGlob = expandHomeTilde(glob);
649
+ if (expandedGlob !== glob) {
650
+ anchors.add(expandedGlob);
651
+ anchors.add(normalizeRuleAnchor(expandedGlob));
652
+ }
653
+ // Match-time cwd anchor for relative rules (host: "relative to current
654
+ // directory"). `**/...` rules already reached absolute candidates; bare
655
+ // `secret/**`, `.env`, `credentials*` did not without this twin.
656
+ if (!isAbsolute(glob) && !glob.startsWith("~")) {
657
+ anchors.add(resolve(projectRoot ?? process.cwd(), glob));
658
+ }
659
+ const variants = new Set();
660
+ for (const variant of anchors) {
661
+ variants.add(variant);
662
+ const canonical = canonicalizeGlob(variant);
663
+ if (canonical !== null)
664
+ variants.add(canonical);
665
+ }
555
666
  // Normalize the glob's path separators the same way candidates were
556
667
  // normalized — otherwise a Windows absolute deny rule like
557
668
  // `Read(C:\Users\...\secret.env)` parses with literal backslashes that
558
669
  // never match a forward-slash candidate.
559
- const regex = fileGlobToRegex(toForward(glob), caseInsensitive);
560
- for (const candidate of candidates) {
561
- if (regex.test(candidate)) {
562
- return { denied: true, matchedPattern: glob };
670
+ for (const variant of variants) {
671
+ const regex = fileGlobToRegex(toForward(variant), caseInsensitive);
672
+ for (const candidate of candidates) {
673
+ if (regex.test(candidate)) {
674
+ return { denied: true, matchedPattern: glob };
675
+ }
563
676
  }
564
677
  }
565
678
  }
@@ -589,6 +702,8 @@ export function evaluateFilePath(filePath, denyGlobs, caseInsensitive = process.
589
702
  *
590
703
  * A path equal to the project root itself counts as inside. Comparison is
591
704
  * case-insensitive on Windows/macOS to match those filesystems' semantics.
705
+ * A leading `~` in `filePath` is expanded to the user's home directory for
706
+ * consistency with `evaluateFilePath`.
592
707
  *
593
708
  * Returns `true` when `projectRoot` is falsy (no boundary to enforce) so the
594
709
  * caller's fail-open posture is preserved when the root cannot be resolved.
@@ -597,7 +712,7 @@ export function isPathInsideProject(filePath, projectRoot, caseInsensitive = pro
597
712
  if (!projectRoot)
598
713
  return true;
599
714
  const root = resolve(projectRoot);
600
- const lexical = resolve(projectRoot, filePath);
715
+ const lexical = resolve(projectRoot, expandHomeTilde(filePath));
601
716
  const within = (root, candidate) => {
602
717
  let a = root;
603
718
  let b = candidate;
package/build/server.js CHANGED
@@ -670,7 +670,7 @@ function getStore() {
670
670
  store.setDenyChecker((filePath) => {
671
671
  try {
672
672
  const denyGlobs = readToolDenyPatterns("Read", storeProjectDir);
673
- const r = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", storeProjectDir);
673
+ const r = evaluateFilePath(filePath, denyGlobs, undefined, storeProjectDir);
674
674
  return r.denied;
675
675
  }
676
676
  catch {
@@ -1139,7 +1139,7 @@ function checkFilePathDenyPolicy(filePath, toolName) {
1139
1139
  try {
1140
1140
  const projectDir = getProjectDir();
1141
1141
  const denyGlobs = readToolDenyPatterns("Read", projectDir);
1142
- const result = evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir);
1142
+ const result = evaluateFilePath(filePath, denyGlobs, undefined, projectDir);
1143
1143
  if (result.denied) {
1144
1144
  return trackResponse(toolName, {
1145
1145
  content: [{
@@ -2127,10 +2127,9 @@ EXAMPLE: ctx_index(path: "/path/to/large-spec.md", source: "openapi-v2-spec")`,
2127
2127
  const store = getStore();
2128
2128
  const projectDir = getProjectDir();
2129
2129
  const denyGlobs = readToolDenyPatterns("Read", projectDir);
2130
- const isWin32 = process.platform === "win32";
2131
2130
  const perFileDeny = (absPath) => {
2132
2131
  try {
2133
- return evaluateFilePath(absPath, denyGlobs, isWin32, projectDir).denied;
2132
+ return evaluateFilePath(absPath, denyGlobs, undefined, projectDir).denied;
2134
2133
  }
2135
2134
  catch {
2136
2135
  return false; // fail-open consistent with checkFilePathDenyPolicy