@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +1 -1
- package/build/adapters/opencode/plugin.d.ts +60 -2
- package/build/adapters/opencode/plugin.js +89 -1
- package/build/adapters/opencode/v2.d.ts +11 -0
- package/build/cli.js +2 -2
- package/build/security.d.ts +44 -10
- package/build/security.js +147 -32
- package/build/server.js +100 -49
- package/build/session/purge.js +2 -1
- package/build/store.d.ts +22 -3
- package/build/store.js +32 -34
- package/cli.bundle.mjs +197 -197
- package/configs/antigravity-cli/plugin.json +1 -1
- package/configs/copilot-cli/.github/plugin/plugin.json +1 -1
- package/hooks/core/routing.mjs +169 -6
- package/hooks/security.bundle.mjs +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +134 -134
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Claude Code plugins by Mert Koseoğlu",
|
|
9
|
-
"version": "2.0.
|
|
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.
|
|
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
|
+
"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
|
+
"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.
|
|
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
|
+
"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:**
|
|
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 {
|
|
53
|
-
import {
|
|
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
|
-
|
|
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,
|
|
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,
|
|
491
|
+
return evaluateFilePath(filePath, denyGlobs, undefined, projectDir).denied;
|
|
492
492
|
}
|
|
493
493
|
catch {
|
|
494
494
|
return false;
|
package/build/security.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
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
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
candidates.add(toForward(
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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;
|