@mxalbert/context-mode 2.0.2 → 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.2"
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.2",
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.2",
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.2",
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.2",
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.2",
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/README.md CHANGED
@@ -1208,7 +1208,7 @@ Indexed content persists in a per-project SQLite database at `~/.context-mode/co
1208
1208
  - **Cache hit (within TTL):** Returns a cache hint (~0.3KB) instead of re-fetching (48KB+). Model proceeds to `ctx_search`.
1209
1209
  - **Cache miss (TTL expired):** Re-fetches silently. No user action needed.
1210
1210
  - **`ttl: 0`** or **`force: true`:** Bypasses cache and re-fetches regardless of freshness.
1211
- - **14-day cleanup:** Content databases and sources older than 14 days are removed on startup.
1211
+ - **14-day cleanup:** Sources older than 14 days are removed from the knowledge base on startup. Content DB files themselves are never auto-deleted — an open-but-idle store is indistinguishable from an abandoned one by timestamps alone, and deleting a live store's files causes unrecoverable I/O errors. Use `ctx_purge` to reclaim disk space.
1212
1212
 
1213
1213
  This means `--continue` sessions preserve indexed docs across restarts. No re-fetching, no wasted context tokens.
1214
1214
 
@@ -49,8 +49,9 @@
49
49
  * - No routing file auto-write (avoid dirtying project trees)
50
50
  * - Session cleanup happens at plugin init (no SessionStart)
51
51
  */
52
- import { type PluginGlobalState } from "./index.js";
53
- import { type PluginClient, type V2SetupContext } from "./v2.js";
52
+ import { SessionDB } from "../../session/db.js";
53
+ import { AdapterPlatformType, OpenCodeAdapter, type PluginGlobalState } from "./index.js";
54
+ import { type PluginClient, type PluginClientAppLogBodyExtra, type V2SetupContext } from "./v2.js";
54
55
  type PluginContext = {
55
56
  client: PluginClient;
56
57
  directory: string;
@@ -192,6 +193,63 @@ export declare function __resetPluginStateForTests(): void;
192
193
  export declare function __getPluginGlobalState(): PluginGlobalState;
193
194
  /** Test-only: close the cached fd and clear the sink cache so each test gets a fresh sink. */
194
195
  export declare function __resetPluginLogSinkForTests(): void;
196
+ type EmitLevel = "info" | "warn" | "error";
197
+ /**
198
+ * Per-plugin-process state shared by the v1 `server()` path and the v2
199
+ * `setup()` path. Both flavors bridge to the SAME handler functions built
200
+ * over this runtime — logic is never duplicated across flavors.
201
+ */
202
+ interface PluginRuntime {
203
+ ctx: PluginContext;
204
+ platform: AdapterPlatformType;
205
+ adapter: OpenCodeAdapter;
206
+ projectDir: string;
207
+ db: SessionDB;
208
+ routing: {
209
+ routePreToolUse: (...args: unknown[]) => any;
210
+ /** v2-native availability signal (hooks/core/routing.mjs) — optional: older routing copies lack it. */
211
+ setContextModeToolsAvailable?: (available: boolean) => void;
212
+ /**
213
+ * v2 permission-evaluate routing (hooks/core/routing.mjs) — optional:
214
+ * older routing copies lack it, and the permission bridge no-ops (fail
215
+ * open, host decision untouched) when absent.
216
+ */
217
+ routePermissionEvaluate?: (action: unknown, resources: unknown, projectDir: unknown, platform: unknown) => {
218
+ effect: "allow" | "deny" | "ask";
219
+ message?: string;
220
+ } | null;
221
+ };
222
+ routingBlock: string;
223
+ autoInjectionMod: {
224
+ buildAutoInjection: (events: unknown) => string;
225
+ };
226
+ captureAgentsMd: (sessionId: string) => void;
227
+ buildNativeTools: () => Promise<Record<string, NativeToolDefinition>>;
228
+ logger: (message?: string, extra?: PluginClientAppLogBodyExtra) => Promise<void>;
229
+ safeLog: (message?: string, extra?: PluginClientAppLogBodyExtra) => Promise<void>;
230
+ logHookError: (hookName: string, err: unknown, sessionId?: string) => void;
231
+ logOnce: (key: string, message: string, level?: EmitLevel) => void;
232
+ /**
233
+ * Liveness gate for v2-registered callbacks (defense-in-depth). Set to
234
+ * true by teardownV2 BEFORE any dispose / claim release: hosts whose
235
+ * registration calls succeed but return NO dispose handle cannot be fully
236
+ * unregistered, so their stale callbacks are neutralized instead — every
237
+ * v2 entry point below checks this flag and no-ops (no throw, no DB touch)
238
+ * once the runtime is torn down. The v1 path never tears a runtime down.
239
+ */
240
+ closed: boolean;
241
+ }
242
+ /**
243
+ * The v2 permission-evaluate bridge handler. Extracted (and exported) so the
244
+ * behavioral contracts are unit-testable: malformed events and a routing
245
+ * throw must leave the host decision untouched (fail-open), and a torn-down
246
+ * runtime must no-op (liveness gate). `route` is pre-bound to the runtime's
247
+ * projectDir/platform by the caller.
248
+ */
249
+ export declare function createV2PermissionEvaluateHandler(rt: Pick<PluginRuntime, "closed" | "logHookError">, route: (action: string, resources: string[]) => {
250
+ effect: "allow" | "deny" | "ask";
251
+ message?: string;
252
+ } | null): (event: unknown) => Promise<undefined>;
195
253
  /**
196
254
  * Plugin factory. Called once when a v1 host (KiloCode/OpenCode ≤ v1) loads
197
255
  * the plugin. Returns an object mapping hook event names to async handler
@@ -1283,6 +1283,85 @@ async function registerEventBusV2(ctx, rt, handlers) {
1283
1283
  dispose: hostDispose ? chainDisposes([abortDispose, hostDispose]) : abortDispose,
1284
1284
  };
1285
1285
  }
1286
+ /**
1287
+ * OPTIONAL: register the v2 permission "evaluate" hook — the surface that
1288
+ * restores true ask/confirmation semantics on OpenCode v2 (verified against
1289
+ * opencode2 beta-19135 live probe; see .research/opencode-v2-permission-hooks.md).
1290
+ *
1291
+ * The host asserts every core-tool action (shell, read, …) through its
1292
+ * permission system before execution and lets the hook MUTATE the decision
1293
+ * (event.effect / event.message). The bridge routes the event through the
1294
+ * shared routing engine's routePermissionEvaluate and applies its verdict:
1295
+ *
1296
+ * - deny → effect "deny" — blocked with the policy reason (normally
1297
+ * pre-empted by the execute.before throw which fires earlier —
1298
+ * this is defense-in-depth for assert paths execute.before cannot see)
1299
+ * - ask → effect "ask" — an interactive confirmation in TUI runs,
1300
+ * restoring the user's ask intent even when host allow rules would
1301
+ * auto-approve; under `--auto` the host auto-approves the ask too
1302
+ * (explicit opt-in, verified live)
1303
+ * - allow → effect "allow" — skips the host's default-ask prompt for
1304
+ * commands the user's own GLOBAL permission rules pre-approve
1305
+ * - null → untouched — the host decision (its rules / default ask / --auto)
1306
+ * stays exactly as computed
1307
+ *
1308
+ * Plugin-registered tools (the ctx_* tools themselves) are NOT
1309
+ * permission-evaluated (empirically verified — see
1310
+ * .research/opencode-v2-empirical-test-results.md), so their gating stays in
1311
+ * tool.execute.before; this hook only covers host core-tool actions, with
1312
+ * zero overlap or double-handling: execute.before keeps deny/modify/context,
1313
+ * and its Stage-1 ask already falls through (opencode is not in
1314
+ * ASK_CAPABLE_PLATFORMS), so the ask decision is expressed HERE, once.
1315
+ *
1316
+ * Fail-open: a routing throw leaves the event untouched (mirrors the
1317
+ * execute.before catch — a routing failure must never brick tool calls).
1318
+ */
1319
+ async function registerPermissionHookV2(ctx, rt) {
1320
+ const permissionHook = ctx?.permission && typeof ctx.permission.hook === "function" ? ctx.permission.hook : undefined;
1321
+ if (!permissionHook || !ctx?.permission)
1322
+ return { ok: false };
1323
+ const routePermissionEvaluate = rt.routing.routePermissionEvaluate;
1324
+ const handler = createV2PermissionEvaluateHandler(rt, (action, resources) => {
1325
+ if (typeof routePermissionEvaluate !== "function")
1326
+ return null; // older routing copy — no-op
1327
+ return routePermissionEvaluate(action, resources, rt.projectDir, rt.platform);
1328
+ });
1329
+ return tryRegister(permissionHook, ctx.permission, "evaluate", handler);
1330
+ }
1331
+ /**
1332
+ * The v2 permission-evaluate bridge handler. Extracted (and exported) so the
1333
+ * behavioral contracts are unit-testable: malformed events and a routing
1334
+ * throw must leave the host decision untouched (fail-open), and a torn-down
1335
+ * runtime must no-op (liveness gate). `route` is pre-bound to the runtime's
1336
+ * projectDir/platform by the caller.
1337
+ */
1338
+ export function createV2PermissionEvaluateHandler(rt, route) {
1339
+ return async (event) => {
1340
+ if (rt.closed)
1341
+ return undefined; // torn down — silent no-op
1342
+ const ev = (event ?? {});
1343
+ const action = typeof ev.action === "string" ? ev.action : "";
1344
+ const resources = Array.isArray(ev.resources)
1345
+ ? ev.resources.filter((r) => typeof r === "string")
1346
+ : [];
1347
+ if (!action || resources.length === 0)
1348
+ return undefined;
1349
+ let routed = null;
1350
+ try {
1351
+ routed = route(action, resources);
1352
+ }
1353
+ catch (err) {
1354
+ rt.logHookError("v2.permission.evaluate", err, v2SessionIdOf(ev));
1355
+ return undefined; // fail-open — host decision untouched
1356
+ }
1357
+ if (!routed)
1358
+ return undefined;
1359
+ ev.effect = routed.effect;
1360
+ if (typeof routed.message === "string")
1361
+ ev.message = routed.message;
1362
+ return undefined; // mutation is the contract — the return value is ignored
1363
+ };
1364
+ }
1286
1365
  /**
1287
1366
  * MANDATORY: register the native ctx_* tools via ctx.tool.transform(editor)
1288
1367
  * (verified v2 API). The ToolEditor receives one ToolInfo per ctx_* tool:
@@ -1591,7 +1670,16 @@ async function setupV2(ctx) {
1591
1670
  missingMessage: "context-mode v2: event bus unavailable (no ctx.event.subscribe) — per-turn token/cost capture inactive",
1592
1671
  });
1593
1672
  disposes.push(...eventBus.disposes);
1594
- rt.logOnce("v2-setup-complete", `context-mode v2 setup complete: native tools via ctx.tool.transform; tool hooks via ${toolRegs.via}; session context: ${sessionContext.status}; prompt capture: ${promptCapture.status}; event bus: ${eventBus.status}`, "info");
1673
+ // Optional: permission "evaluate" hook true ask/allow security
1674
+ // semantics on permission-evaluated actions (see registerPermissionHookV2).
1675
+ const permissionHook = await attemptOptionalV2(activeRt, {
1676
+ surfacePresent: typeof ctx?.permission?.hook === "function",
1677
+ register: () => registerPermissionHookV2(ctx, activeRt),
1678
+ logKeyMissing: "v2-permission-hook-missing",
1679
+ missingMessage: "context-mode v2: permission evaluate hook unavailable (no ctx.permission.hook) — ask/allow security semantics inactive; deny enforcement stays on tool.execute.before",
1680
+ });
1681
+ disposes.push(...permissionHook.disposes);
1682
+ rt.logOnce("v2-setup-complete", `context-mode v2 setup complete: native tools via ctx.tool.transform; tool hooks via ${toolRegs.via}; session context: ${sessionContext.status}; prompt capture: ${promptCapture.status}; event bus: ${eventBus.status}; permission hook: ${permissionHook.status}`, "info");
1595
1683
  return async () => {
1596
1684
  // Cleanup: unregister everything registered, close the runtime DB
1597
1685
  // handle, then release the activation claim so a reload can re-claim.
@@ -87,6 +87,17 @@ export type V2SetupContext = {
87
87
  signal?: AbortSignal;
88
88
  }) => unknown;
89
89
  };
90
+ /**
91
+ * v2 permission domain (verified against opencode2 beta-19135 live probe:
92
+ * ctx.permission.hook("evaluate", cb) fires for every core-tool permission
93
+ * assert with a MUTABLE event { action, resources, source?, effect, message? }
94
+ * — hook mutations of effect/message win the decision). Optional: older v2
95
+ * builds and all v1 hosts lack the surface; the plugin degrades to the
96
+ * execute.before throw-based behavior there.
97
+ */
98
+ permission?: {
99
+ hook?: (name: string, cb: (event: unknown) => unknown) => unknown;
100
+ };
90
101
  };
91
102
  /**
92
103
  * Convert a Zod (v3-classic) schema into a JSON-Schema object — the shared
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;