Repository navigation
Description
TL;DR — parseToolPattern passes the text inside Read(...) to the glob converter as-is, so the two anchors Claude Code documents as absolute never match anything. Two consequences: ctx_execute_file blocks paths the user explicitly allowed, and the deny gate silently lets through paths the user explicitly denied. Only the /path form matches, and that is the one form Claude Code does not resolve against the filesystem root. Minimal fix is to normalize // and ~/ before globbing.
Platform
Claude Code
context-mode version
1.0.169
Debug script output (REQUIRED)
The four failing checks in the report below are FTS5 in-memory test, Session DB uses WAL mode, No orphaned -shm files, and Concurrent SQLite writes (3 conns × 30). None of them touch this bug: the reproduction calls the shipped permission matcher directly and goes nowhere near SQLite.
ctx-debug.sh output (config file contents redacted, username and hostname replaced)
{
"version": "2.0.0",
"generated": "2026-08-24T08:54:57Z",
"sections": {
"1. System Info": {
"checks": [],
"info": {
"OS type": "macos",
"uname -a": "Darwin HOSTNAME 25.6.0 Darwin Kernel Version 25.6.0: Fri Jul 31 19:16:36 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_T6030 arm64",
"Architecture": "arm64",
"macOS version": "26.6.2 (25G83)",
"Shell": "/bin/zsh",
"Bash version": "3.2.57(1)-release",
"Locale": "LANG=ja_JP.UTF-8, LC_ALL=unset"
},
"configs": [],
"warnings": [],
"details": []
},
"2. Runtime Versions": {
"checks": [],
"info": {
"Node.js": "v26.5.0",
"node path": "/Users/USER/.local/share/mise/installs/node/26.5.0/bin/node",
"execPath": "/Users/USER/.local/share/mise/installs/node/26.5.0/bin/node",
"Node install method": "unknown",
"Bun": "1.4.0",
"bun path": "/Users/USER/.local/share/mise/installs/bun/latest/bin/bun",
"Codex CLI": "codex-cli 0.149.1",
"Python": "Python 3.14.7",
"Ruby": "ruby 2.6.10p210 (2022-04-12 revision 67958) [universal.arm64e-darwin25]",
"npm": "12.0.2",
"npm global root": "/Users/USER/.local/share/mise/installs/node/26.5.0/lib/node_modules"
},
"configs": [],
"warnings": [
"Codex ≥0.118.0: exec-mode MCP broken (openai/codex#16685). Pin to ≤0.116.0 for exec-mode."
],
"details": []
},
"3. context-mode Installation": {
"checks": [
{
"pass": true,
"label": "build/ directory exists"
},
{
"pass": true,
"label": "hooks/pretooluse.mjs exists"
},
{
"pass": true,
"label": "hooks/sessionstart.mjs exists"
},
{
"pass": true,
"label": "server.bundle.mjs exists"
},
{
"pass": true,
"label": "cli.bundle.mjs exists"
}
],
"info": {
"Installed version": "1.0.169",
"Plugin root": "~/.claude/plugins/cache/context-mode/context-mode/1.0.169",
"Install method": "marketplace",
"npm latest": "1.0.169"
},
"configs": [],
"warnings": [],
"details": []
},
"4. better-sqlite3 Native Module": {
"checks": [
{
"pass": true,
"label": "better-sqlite3 .node binary exists"
},
{
"pass": true,
"label": "require('better-sqlite3') succeeds"
},
{
"pass": true,
"label": "npm ignore-scripts is false"
}
],
"info": {
"Binary path": "~/.claude/plugins/cache/context-mode/context-mode/1.0.169/node_modules/better-sqlite3/build/Release/better_sqlite3.abi147.node",
"Node ABI version": "147"
},
"configs": [],
"warnings": [],
"details": []
},
"5. Adapter Detection": {
"checks": [],
"info": {
"Active adapter (env)": "none",
"Installed adapters": "claude-code codex gemini-cli"
},
"configs": [],
"warnings": [],
"details": []
},
"6. Config Files": {
"checks": [],
"info": {
"Claude settings.json": "~/.claude/settings.json (exists)",
"Claude settings.local.json": "~/.claude/settings.local.json (not found)",
"Gemini settings.json": "~/.gemini/settings.json (exists)",
"Codex config.toml": "~/.codex/config.toml (exists)",
"Codex hooks.json": "~/.codex/hooks.json (exists)",
"Cursor hooks.json (project)": "~/.claude/.cursor/hooks.json (not found)",
"Cursor mcp.json (project)": "~/.claude/.cursor/mcp.json (not found)",
"Cursor mcp.json (global)": "~/.cursor/mcp.json (not found)",
"Cursor hooks.json (global)": "~/.cursor/hooks.json (not found)",
"VS Code mcp.json (project)": "~/.claude/.vscode/mcp.json (not found)",
"opencode.json (project)": "~/.claude/opencode.json (not found)",
"opencode.json (dotdir)": "~/.claude/.opencode/opencode.json (not found)",
"opencode.json (global)": "~/.config/opencode/opencode.json (not found)",
"KiloCode settings.json": "~/.config/kilo/settings.json (not found)",
"OpenClaw openclaw.json": "~/.openclaw/openclaw.json (not found)",
"Kiro mcp.json": "~/.kiro/settings/mcp.json (not found)",
"Kiro default agent": "~/.kiro/agents/default.json (not found)",
"Zed settings.json": "~/.config/zed/settings.json (not found)"
},
"configs": [
{
"name": "Claude settings.json",
"path": "~/.claude/settings.json",
"exists": true,
"content": "[redacted]"
},
{
"name": "Gemini settings.json",
"path": "~/.gemini/settings.json",
"exists": true,
"content": "[redacted]"
},
{
"name": "Codex config.toml",
"path": "~/.codex/config.toml",
"exists": true,
"content": "[redacted]"
},
{
"name": "Codex hooks.json",
"path": "~/.codex/hooks.json",
"exists": true,
"content": "[redacted]"
}
],
"warnings": [],
"details": []
},
"7. Hook Validation": {
"checks": [
{
"pass": true,
"label": "No stale hook paths in Claude settings.json"
},
{
"pass": true,
"label": "PreToolUse registered"
},
{
"pass": true,
"label": "PostToolUse registered"
},
{
"pass": true,
"label": "PreCompact registered"
},
{
"pass": true,
"label": "SessionStart registered"
}
],
"info": {},
"configs": [],
"warnings": [],
"details": []
},
"8. SQLite / FTS5 Test": {
"checks": [
{
"pass": false,
"label": "FTS5 in-memory test"
}
],
"info": {},
"configs": [],
"warnings": [],
"details": []
},
"9. Executor Test": {
"checks": [
{
"pass": true,
"label": "node subprocess spawn"
},
{
"pass": true,
"label": "bash subprocess spawn"
}
],
"info": {},
"configs": [],
"warnings": [],
"details": []
},
"10. Process Check": {
"checks": [],
"info": {
"Running context-mode processes": "7"
},
"configs": [],
"warnings": [
"Multiple context-mode processes — possible orphans"
],
"details": []
},
"11. Session Databases": {
"checks": [],
"info": {
"~/.claude/context-mode/sessions": "143 files, 90M",
"Total session DBs": "143"
},
"configs": [],
"warnings": [],
"details": []
},
"12. Environment Variables": {
"checks": [],
"info": {
"TMPDIR": "/var/folders/hk/dlx6twc54jq9c_zw10n5hdq40000gp/T/",
"TEMP": "unset",
"TMP": "unset",
"NODE_OPTIONS": "--require=/var/folders/hk/dlx6twc54jq9c_zw10n5hdq40000gp/T/cmux-claude-node-options/restore-node-options.cjs --max-old-space-size=4096",
"NODE_PATH": "/Users/USER/.claude/plugins/cache/context-mode/context-mode/1.0.169/node_modules:",
"NODE_EXTRA_CA_CERTS": "/Users/USER/ca/Cloudflare_CA.pem",
"SSL_CERT_FILE": "/Library/Python/3.9/site-packages/certifi/cacert.pem",
"CURL_CA_BUNDLE": "unset",
"SSH_AUTH_SOCK": "yes (set)",
"CONTEXT_MODE_SESSION_SUFFIX": "unset",
"CONTEXT_MODE_NODE": "unset"
},
"configs": [],
"warnings": [],
"details": []
},
"13. Hook Execution": {
"checks": [
{
"pass": true,
"label": "PreToolUse denies WebFetch"
},
{
"pass": true,
"label": "PreToolUse passes Write tool through"
},
{
"pass": true,
"label": "SessionStart injects routing context"
}
],
"info": {},
"configs": [],
"warnings": [],
"details": []
},
"14. MCP Server Startup": {
"checks": [
{
"pass": true,
"label": "Server entry + deps resolve"
}
],
"info": {},
"configs": [],
"warnings": [],
"details": []
},
"15. SQLite Concurrency": {
"checks": [
{
"pass": false,
"label": "Session DB uses WAL mode"
},
{
"pass": true,
"label": "No oversized WAL journals (>1MB)"
},
{
"pass": false,
"label": "No orphaned -shm files"
},
{
"pass": false,
"label": "Concurrent SQLite writes (3 conns × 30)"
}
],
"info": {},
"configs": [],
"warnings": [],
"details": []
},
"16. Adapter Validation": {
"checks": [
{
"pass": true,
"label": "Adapter validation passes"
}
],
"info": {
"Validated adapter": "Claude Code"
},
"configs": [],
"warnings": [],
"details": []
},
"17. Sandbox Environment": {
"checks": [
{
"pass": true,
"label": "System CA certificates found"
},
{
"pass": true,
"label": "Temp dir writable"
},
{
"pass": true,
"label": "Env denylist strips dangerous vars"
}
],
"info": {
"Effective temp dir": "/var/folders/hk/dlx6twc54jq9c_zw10n5hdq40000gp/T/"
},
"configs": [],
"warnings": [],
"details": []
},
"18. Network / TLS": {
"checks": [
{
"pass": true,
"label": "NODE_EXTRA_CA_CERTS file exists"
},
{
"pass": true,
"label": "HTTPS to npm registry"
},
{
"pass": true,
"label": "Temp dir >100MB free"
}
],
"info": {},
"configs": [],
"warnings": [],
"details": []
}
},
"summary": {
"pass": 27,
"fail": 4,
"warnings": 2,
"total": 31
}
}Exact prompt that triggered the bug (REQUIRED)
Count the lines of the scan script I just wrote in the scratchpad directory.
Full error output (REQUIRED)
File access blocked: "/private/tmp/claude-UID/<session>/scratchpad/scan.mjs" resolves outside the
project root (/Users/USER/.claude). context-mode confines ctx_execute_file to the workspace so it
cannot be used to bypass the host's sandbox/permission controls (issue #852). To intentionally
process a file outside the project, add a host allow rule, e.g. "permissions": { "allow":
["Read(/private/tmp/claude-UID/<session>/scratchpad/scan.mjs)"] } in your settings.
The rule that message suggests uses a single leading slash, which Claude Code resolves against the settings source rather than the filesystem root — so following the advice verbatim produces a rule that grants nothing on the host side.
Steps to reproduce (REQUIRED)
Allow side, through the tool. This is the path I hit.
-
Add an absolute-path allow rule to
~/.claude/settings.json, in the form Claude Code writes and documents:{ "permissions": { "allow": ["Read(//private/tmp/**)"] } } -
From a project rooted somewhere else, call
ctx_execute_fileon a file under/private/tmp/. -
The call is blocked with the message above, even though the rule covers the path.
Both sides, against the matcher directly. Run these from the installed plugin root, or from a checkout after npm run build:
$ cd ~/.claude/plugins/cache/context-mode/context-mode/1.0.169
$ node --input-type=module -e "
import { evaluateProjectContainment as e } from './build/security.js';
// (path, projectRoot, allowGlobs) — allowGlobs is one array per settings file
const p = '/Users/alice/notes/todo.md', root = '/Users/alice/some-project';
for (const g of ['//Users/alice/notes/**', '~/notes/**', '/Users/alice/notes/**'])
console.log(JSON.stringify(g), '=>', JSON.stringify(e(p, root, [[g]])));
"
"//Users/alice/notes/**" => {"allowed":false,"reason":"outside"}
"~/notes/**" => {"allowed":false,"reason":"outside"}
"/Users/alice/notes/**" => {"allowed":true,"reason":"allow-rule"}$ node --input-type=module -e "
import { evaluateFilePath as f } from './build/security.js';
// (path, denyGlobs, caseInsensitive, projectRoot) — denied:true means a deny rule matched
const p = '/Users/alice/project/.env';
for (const g of ['//Users/alice/project/**', '~/project/**', '/Users/alice/project/**', '**/.env'])
console.log(JSON.stringify(g), '=>', JSON.stringify(f(p, [[g]], true, '/Users/alice/project')));
"
"//Users/alice/project/**" => {"denied":false}
"~/project/**" => {"denied":false}
"/Users/alice/project/**" => {"denied":true,"matchedPattern":"/Users/alice/project/**"}
"**/.env" => {"denied":true,"matchedPattern":"**/.env"}The second run is the one that matters: a user who writes Read(//Users/alice/secrets/**) under permissions.deny gets no protection from the deny gate. Relative patterns such as **/.env still match, so the gate is not broken across the board — only for the two absolute anchors.
Deny side, from a settings file. readToolDenyPatterns takes an explicit globalSettingsPath, so the read-parse-match chain can be driven without touching real settings. With {"permissions": {"deny": ["Read(//Users/alice/project/secret.txt)"]}} in a scratch file:
$ node --input-type=module -e "
import { readToolDenyPatterns, evaluateFilePath } from './build/security.js';
const g = readToolDenyPatterns('Read', undefined, '/tmp/fake-settings.json');
console.log('parsed deny globs:', JSON.stringify(g));
console.log('verdict:', JSON.stringify(evaluateFilePath('/Users/alice/project/secret.txt', g, true, '/Users/alice/project')));
"
parsed deny globs: [["//Users/alice/project/secret.txt"]]
verdict: {"denied":false}The rule is read and parsed correctly, and then matches nothing. The only hop I did not execute is src/server.ts:1220-1227 calling these two functions, which is visible in the source.
What have you tried to fix it?
The host is not at fault here. Claude Code's permission documentation defines the anchors:
| Pattern | Meaning | Example |
|---|---|---|
//path |
Absolute path from filesystem root | Read(//Users/alice/secrets/**) |
~/path |
Path from home directory | Read(~/Documents/*.pdf) |
It warns explicitly: "A pattern like /Users/alice/file isn't an absolute path. The single leading slash anchors at the settings source, not the filesystem root. Use //Users/alice/file for absolute paths." For deny rules it steers users to the same forms: "To write a rule in user settings that applies inside every project, use a // absolute path or a ~/ home-relative path instead."
Where it comes from. parseToolPattern (src/security.ts:37-44) returns the raw text between the parentheses. readToolPermissionPatterns (src/security.ts:426, pushed at 455-457) collects it, evaluateFilePath hands it to fileGlobToRegex (src/security.ts:101-135, called at 646), and that converter escapes everything except *, **, and ?. Nothing strips the extra leading slash and nothing expands ~ — homedir does not appear in src/security.ts at all. So //private/tmp/** can only ever match a path that literally begins with two slashes.
Both consumers inherit it: the allow-rule escape hatch in evaluateProjectContainment (src/security.ts:766-783, reached from src/server.ts:1189-1192) and the deny gate (readToolDenyPatterns at src/security.ts:406-412, reached from src/server.ts:1220-1227). Line numbers are from the 1.0.169 tag; src/security.ts is identical on next, src/server.ts is not.
Workaround, allow side. Add a second rule in the single-slash form next to the documented one:
"Read(/private/tmp/claude-*/**/scratchpad/**)"This matcher accepts that entry. On the Claude Code side it resolves to ~/.claude/private/tmp/..., so it grants nothing and exists only to satisfy context-mode. Verified here: after adding it, the same ctx_execute_file call succeeds, with no restart.
Workaround, deny side. Rewrite any // or ~/ deny rule you rely on as a relative pattern — Read(**/secrets/**) rather than Read(//Users/alice/secrets/**). Relative patterns still match, as the run above shows.
Suggested fix, minimal. Normalize the anchor once, where the pattern is read:
//path— drop one leading slash and match from the filesystem root~/path— expand againsthomedir()
That restores both the allow escape hatch and the deny gate.
Separate, larger. Resolving a single leading / against the directory of the settings file the rule came from, as the docs describe, needs the pattern's provenance threaded through readToolPermissionPatterns. Worth doing, but it is a different change and the two above stand on their own.
Pre-submission checklist
- I have run the debug script and pasted the output above
- I am using the latest version of context-mode
- I have searched existing issues for duplicates
- I have included steps to reproduce the issue
Operating System
macOS (Darwin 25.6.0)
JS Runtime
node v26.5.0 / bun 1.4.0
Activity
Port upstream PR #1087: expand // and ~/ anchors in permission patterns
Add ci/verify.mjs: end-to-end hardening checks (pnpm verify)
mksglu commentedon Nov 4, 2026
2.0 understands the
~/and//absanchors in your deny rules. A deny forRead(~/secret/**)orRead(//etc/**)now blocks the matching file, and tests cover both.Context Mode 2.0 is out. To try it in Claude Code:
Please test it, and reopen this issue or open a new one with your
ctx_doctoroutput if it still happens: https://github.com/mksglu/context-mode/issues/new?template=agent-report.yml