The MCP server's default project comes from the client (initialize's
rootUri or first workspaceFolders entry, or its roots/list answer), the
launch cwd, or --path. Only an agent's projectPath argument went through
the sensitive-folder guard of PR #2929: the default root was opened with
no check, and opening it also started a file watcher and a catch-up sync,
and from the launcher a shared daemon keyed on that folder. A session
whose default was ~/.config, ~/.ssh or a system folder holding an index
served the files inside to the agent. (On main a warm query pool happened
to refuse such a call with isError; before it warmed, the main thread
served it.)
resolveServerRoot, which every default-project resolution goes through
(the engine's open and its per-call retry, the initialize handshake and
the launcher's daemon root), now applies the refusal a projectPath gets:
on the path searched from when it exists, and on the root it lands on,
by up-walk or by the sub-project down-scan. A refused root resolves to
null with the reason, so the session has no default project. Nothing
there is opened, watched or synced; no daemon, lock or socket is set up
in the folder; initialize sends the no-default-project instructions; and
a call without projectPath gets the usual success-shaped "pass
projectPath" guidance, saying why. A call that names the folder keeps its
PathRefusalError, and sub-projects below a refused folder aren't listed.
--path is refused the same way, as `codegraph ui` refuses its root, but
the server stays up with no default project instead of exiting, so the
agent keeps codegraph for its other projects. A daemon started by hand
for such a folder exits. The sensitive-folder half of validateProjectPath
is split out as sensitiveDirectoryRefusal; validateProjectPath behaves
as before.
Validation on Windows 11:
- 6 new tests in security.test.ts. On main's sources every entry point
is red: the engine opened ~/.config from the folder, a folder inside
it, a missing folder under it and a junction to it, serving a fake
token and taking writer.pid; rootUri, workspaceFolders, cwd and
roots/list sessions served the token; the down-scan adopted or listed
sub-projects; a launch from the cwd started a daemon in the folder.
The ordinary-project control passes on both. All 6 pass with the fix.
- security.test.ts: 70 passed, 4 skipped (POSIX and macOS gates). 51
MCP, daemon, proxy and directory suites: 646 passed, 0 failed.
- The real serve --mcp against a private HOME, from a sensitive cwd and
with a sensitive --path: no-default instructions, the guidance instead
of the token, no daemon.pid or writer.pid. An ordinary project is
unchanged.
- Cost: resolveServerRoot 141 -> 377 us per call on an ordinary project
(median of 7 rounds of 2000 calls). It runs at the handshake, the
engine open and the launcher, and per tool call only while a session
has no default project.
Follow-up to PR #2929.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A full index reads every path the scan lists, and `git ls-files` keeps
listing a tracked file deleted from the working tree until the deletion is
staged. So `codegraph init` and `codegraph index` stat'ed the missing file,
got ENOENT and reported "1 files could not be read" with an errors.log
entry. The errored file also kept the run from recording the commit it
indexed, so `codegraph status` fell back to a full scan afterwards.
The batch reader in indexAll and the single-file indexFile now take ENOENT
and ENOTDIR to mean nothing is there: the file is skipped and counted as
skipped, so the run reads as complete rather than PARTIAL. A symlink that
points at nothing is skipped the same way, as the directory walk for
projects without git already did. Every other read failure (EACCES, EIO, a
folder where a file was) is still reported. On POSIX a path under a file
fails realpath with ENOTDIR, which validatePathWithinRoot rejected as if it
escaped the root; it is now judged like any missing leaf, by its nearest
existing ancestor, so it reaches the reader and is skipped too. Discovery
is unchanged, with no extra stat per tracked file.
Validation:
- index-gone-tracked-files.test.ts (new): all 5 cases fail on main's
source and pass with the fix: committed and staged deletions, a folder
replaced by a file, a dangling symlink, and EACCES still reported by
both the full index and indexFiles. Mutating the single-file path, the
skipped tally or the EACCES boundary fails the test each time.
- security.test.ts: a path under an existing file validates, and one under
a symlink to a file outside the root is still refused.
- The issue's CLI repro: main prints "1 files could not be read" and writes
errors.log; the fix prints "Indexed 1 files", writes no errors.log, and
status reports nothing pending.
Reported in PR #2910 by @xianzhi-xzw, who also filed issue #2904. This
reimplements the fix independently, at read time rather than with an lstat
of every tracked file during discovery.
Fixes#2904
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project at the root of a drive, such as a mapped network drive Z:\, a
subst drive or a UNC share root, indexed nothing on Windows: every file
was refused as "Path traversal blocked", and code reads under the root
were refused too. The containment check appended a separator to the root
before comparing, and a root such as C:\ already ends with one, so the
prefix became C:\\ and no path under the root matched. The dead code
report's default file reader had its own copy of the same check, so it
treated every file of such a project as unreadable.
The check now adds a separator only when the root doesn't already end
with one. The dead code reader uses the shared lexical containment check
instead of its own copy.
Validation on Windows 11:
- subst R: onto a two-file fixture, then codegraph init -y --force R:\:
main indexes 0 files ("all 2 files had errors", 2 "Path traversal
blocked"), and validatePathWithinRoot(R:\, a.ts) is null. The fix
indexes both files (6 nodes, 6 edges) and getCode returns both
functions' source. The mapping was removed after each run.
- Dead code report against one R:\ index: main's reader drops the unused
function as unreadable; the fix reports it.
- Tests: 3 new tests fail on main's source and pass with the fix: a file
under the filesystem root (this drive's root here, / on POSIX), path
math under Q:\ and \\server\share\, and read-only realpaths under C:\.
Reported in PR #415 by @Kamilslaoui93; this reimplements the fix
independently.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard that keeps ~/.ssh, ~/.gnupg, ~/.aws, ~/.config and system
folders such as /etc and C:\Windows from being opened as a project
compared the path as typed. A junction or symlink to one of them, a
different capitalization on Windows or macOS (~/.SSH), or a \\?\ or \\.\
spelling on Windows passed, so an MCP projectPath, `codegraph ui` and the
viewer's per-file reads all served what was inside. The MCP gate also
skipped the guard for a path that doesn't exist and then walked up from
it, so ~/.config/no-such-dir reached an index of ~/.config.
validateProjectPath now checks the path as spelled (with a Win32
namespace prefix stripped) and its fs.realpathSync.native form. Both are
compared with the system set and the system folders' own real paths
(macOS /private/etc), and with the home folders as spelled, under the
home's real path and by their own real paths (a linked ~/.ssh). Case is
folded on win32 and darwin. A path that can't be resolved keeps the
lexical check. It stays at one realpath per call: the system folders'
real paths are read once and the home folders' are cached for two
seconds. The MCP handler also checks the root its up-walk lands on.
Validation on Windows 11, with a private home holding a fake token:
- Probe: on main 11 of 16 sensitive spellings were allowed, and the
token was served through 6 MCP projectPath shapes and 2 viewer roots.
With the fix all are refused. Controls (an ordinary folder, its
upper-case and \\?\ spellings, the home itself) are still allowed.
- codegraph ui end to end: main served the token for 4 of 6 spellings
and started the viewer for a fifth; the fix refuses all 6 at startup.
- Tests: the 9 new tests that run on Windows fail on main's source and
pass with the fix. The POSIX and macOS cases are gated and were not
run here.
- Cost: validateProjectPath 48 -> 130 us per call, the viewer's per-file
resolveProjectFile 700 -> 780 us (medians of 5 rounds, 3 runs).
Reported in PR #1930 by @mixxer; this reimplements the fix independently.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
validatePathWithinRoot returned the lexical path whenever the path did
not exist yet (ENOENT). With .codegraph/ui a link to a folder outside
the project and no trails folder yet, the trails folder passed the
check, mkdir followed the link, and the trail was written outside the
project. The save still answered 403, but only because the list it
sends back re-checked the folder that now existed. The trails list also
joined each file name onto the checked folder and read it with statSync
and readFileSync, which follow links, so a trail file linking to a
trail-shaped file outside the project was listed and served.
validatePathWithinRoot now judges a missing path by its nearest
existing ancestor, and returns null when the root itself is missing.
The list reads every file through the same per-file check as a single
read, opens it with O_NOFOLLOW | O_NONBLOCK where the platform has
them, and checks the open file with fstat. Windows has neither flag;
there the per-file check is what refuses a planted link. A save
re-checks the folder after mkdir and writes its temp file under a
random name with flag wx and mode 0600. The trail author is cached per
project instead of per process, and a trail file that declares an
unknown format version is skipped.
Added on top of the contributor's change: the link tests also run on
Windows (a junction and a directory symlink at .codegraph/ui, a file
symlink in the trails folder), each test removes its own links before
deleting its folder, the helper gets a junction case and three
controls (an in-root link above a missing leaf, allowSymlinkEscape
above a missing leaf, a missing root), its doc comment names the new
missing-path rules, and the viewer changelog lines are reworded and
moved next to the other saved-trails line.
Validation: 8 tests fail on main and pass here (the helper below a
symlink and below a junction, a missing root, a save below a junction
and below a symlink, a linked trail file, the per-project author, the
format version). Through the real CLI (codegraph ui), main wrote the
trail outside the project through a junction and through a directory
symlink, and listed a linked outside file with its note; this branch
refused all three and wrote nothing outside. Graph dumps of express,
zod, excalidraw, flask and gin are byte-identical to main's, and 55 CLI
read commands and 45 MCP tool calls answer the same (codegraph_status
differs only by its last-indexed time). Index time is unchanged (gin
over 9 interleaved rounds: 2645 ms on main, 2687 ms here). Listing 200
saved trails, the list's cap, costs 108 ms on main and 236 ms here on
Windows; 10 trails cost 6 ms and 14 ms.
Original PR #1667 by @danusha2345.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(lock): keep a live writer lock regardless of file age (#1959)
* fix(watcher): re-arm after lock contention and reconcile before trusting index (#1959)
* feat(mcp): report exact index freshness without blocking the daemon (#1959)
* fix(mcp): refuse drifted explore source after watcher degradation (#1959)
* fix(mcp): refuse graph answers that name a drifted file after watcher degradation (#1959)
Explore already refuses to render source from a file that changed while
auto-sync was off. search, callers, callees and impact answer from the same
frozen graph but kept replying, naming the changed file as if its edges were
current. They now take the same refusal: the indexed files an answer names
as locations are hashed against the index, and a drifted one is named
instead of the answer. codegraph_node keeps its own drift gate, which serves
the changed file's current bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sync): recover index freshness after lock contention (#1959)
Keep a live writer's lock regardless of its age.
Re-arm lock-degraded watchers with throttling and retain stale state until full reconciliation succeeds.
Validate contributing files through structured paths and hashes, refusing incomplete checks without tool errors.
Report bounded freshness measurements and cover Git fallback exclusions.
Co-authored-by: danusha2345 <danusha2345@users.noreply.github.com>
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: danusha2345 <danusha2345@users.noreply.github.com>
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`isGeneratedFile` was path-only, but Go's own convention is a CONTENT
marker (`// Code generated by <tool>. DO NOT EDIT.`), not a filename one.
A Go monorepo with generated CRUD in ordinarily-named files sitting beside
hand-written use-cases was therefore invisible to every generated-file
down-rank in the codebase — that is #1500.
Measured on kubernetes/client-go (2,453 Go files): the canonical banner
appears in 2,001 of them, the path check flags 0, the new content check
flags exactly those 2,001 — no false positives, no misses.
Design: decide at INDEX time (content is already in memory for parsing),
persist on `files.generated`, read from the DB. Explore never reads file
headers per request.
- `hasGeneratedHeader(content)` recognizes the standard banners — Go's,
protoc's, `@generated`, `<auto-generated>`, Thrift, OpenAPI Generator,
FlatBuffers, bindgen, ANTLR. Precision-first and fenced three ways: an
8KB/60-line header window, a comment-line requirement (leader or open
block comment), and markers tight enough that prose can't trip them. A
generator's own source, holding the banner as a string constant in its
body, is not flagged; neither is this module itself (pinned by test).
- `isGeneratedFile(path)` is unchanged — cheap, sync, still the fallback.
- Schema v9 adds `files.generated` + a PARTIAL index. DDL only, no
backfill: the flag derives from content the migration cannot see, so
rows stay 0 until a re-index and every reader unions the flag with the
path check — an un-migrated index keeps pre-#1500 behavior rather than
regressing. Re-index required; noted in the CHANGELOG.
- `generatedPredicateFor(paths)` gives ranking a bounded probe + O(1)
lookups. Bounded, not cached: no invalidation, so a ranking call can
never serve a verdict the last sync already replaced. Wired into explore
ranking, findSymbolMatches, findAllSymbols, search (MCP + CLI), the
context formatter, and the dominant-file/route-file hygiene filters.
Cost (acceptance bar was no measurable index-time regression): a single
unanchored `/generat/i` test over the header rejects ~every hand-written
file before any line splitting. 4.6 µs/file on client-go (worst case —
82% generated). End-to-end `codegraph init` on client-go, n=3 alternating
arms: 5.73s median with detection vs 5.76s path-only baseline; the arms
cross over between runs, so the difference is inside run-to-run noise.
Scope note: generated status remains a stable TIEBREAK at equal score,
exactly where it was. Making it a strong negative signal is CG-10, which
this unblocks by making the signal correct and available.
Two pre-existing tests hard-coded schema version 8; both now track
CURRENT_SCHEMA_VERSION (or the migration table) so future migrations
don't require editing them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The directory walk deliberately follows an in-root symlink whose target
lives outside the repo root (the standard Dota custom-game layout, where
`game/` and `content/` link into the SDK tree) and enumerates the files
under it. But the read path then rejected every one of them via the
strict symlink-escape guard, logging `Path traversal blocked in batch
reader` and indexing nothing — discovery and the reader disagreed.
Add an opt-in `allowSymlinkEscape` to validatePathWithinRoot that waives
only the realpath-escape rejection (the lexical `../` guard still
applies) and pass it at the three indexing read sites (batch reader,
indexFile, indexFileWithContent). The content-serving sinks
(ContextBuilder, MCP tools) keep the strict guard, so this stays inside
the #527 model: indexing now follows the symlink, getCode still refuses
to serve out-of-root contents.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(security): resolve symlinks in path validation to block out-of-root reads (#527)
validatePathWithinRoot was purely lexical (path.resolve + startsWith), so an
in-repo symlink whose logical path is inside the project root but whose real
target escapes it passed validation — and both content-serving read sinks
(codegraph_node includeCode, codegraph_explore source) then readFileSync'd it,
leaking out-of-root file contents (e.g. ~/.ssh, /etc) to the agent.
Add a realpath layer: after the lexical check, resolve symlinks on both the
candidate path and the root and re-compare, rejecting anything whose real path
escapes the root. An in-root symlink is still allowed (no over-blocking).
Comparison is case-insensitive on Windows (NTFS + realpath casing). Not-yet-
existing paths (ENOENT) fall back to the lexical result so about-to-be-written
files still validate; other resolution errors reject.
Removes the dead, never-called isPathWithinRoot / isPathWithinRootReal helpers
(the latter a footgun — it returned true on realpath failure). Adds RED->GREEN
tests: in->out file/dir symlinks rejected, in->in allowed, ../ rejected, ENOENT
allowed, plus an end-to-end test proving getCode no longer serves an out-of-root
file reached through a dir symlink.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(changelog): note the #527 symlink path-escape fix
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
Completes the explore-overhaul arc: `codegraph_explore` becomes the single primary tool an agent reaches for, and its coverage + output shape are tuned so flow/architecture questions resolve with near-zero Read/Grep.
### What changed
- **explore is the sole primary tool** — removed `codegraph_context` (the fuzzy-input Read-trigger) and `codegraph_trace` (under-picked by agents); explore already surfaces the call flow among the symbols you name. A plain natural-language question now works as the query.
- **Store/handler coverage** — functions defined inside object literals (Zustand `create((set, get) => ({ … }))`, Redux/Pinia/MobX, exported handler/route maps) are indexed as real symbols, including calls through `useStore.getState().fn()` and destructured `const { fn } = useStore.getState()`. A general AST rule, not a per-lib hack.
- **Overload disambiguation** — explore leads with the *right* definition when a method name is overloaded across types (a PascalCase type token in the query biases to that type's own def); `codegraph_node` returns *every* overload's body in one call, with an optional `file`/`line` selector to pin one.
- **Method-atomic render** — explore never returns half a method; at the size budget it drops whole methods/files (and lists what it dropped) instead of truncating a body mid-method.
- **Native-read-shaped output** — per-call output is capped to ~24K with a 25K hard ceiling and concentrated into ~150–250-line flow windows, mirroring how the agent natively reads; repo size scales the *call* budget, not the per-call size (a larger response just gets externalized to a file the host Reads back).
- **Blast radius** folded into explore (dependents + covering tests, locations only).
### Benchmark (refreshed on this build)
Re-validated the 7-repo A/B on 2026-06-02 (Opus 4.8, effort=high, median of 4). WITH arm re-measured on this build, WITHOUT reused:
**~16% cheaper · 47% fewer tokens · 22% faster · 58% fewer tool calls** — 0 file reads on 6 of 7 repos (Gin ~1).
The arc trades larger, cache-heavy explore responses for guaranteed near-zero reads, so cost/token margins soften vs the prior build (Excalidraw and Tokio land at cost break-even) while time and tool-calls stay clear wins everywhere — consistent with the project's stated optimization target (latency + tool-calls, not token cost).
### Validation
- Full suite green: **1112 passed, 2 skipped**.
- 28/28 plain WITH runs across the 7 README repos completed clean; reads median 0 on 6/7.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Validate projectPath in getCodeGraph so MCP clients can't open a codegraph in a
sensitive system directory. Guarded with existsSync so nested/not-yet-created
sub-paths still resolve up to the default project (preserves issue #238). Adds
MCP-handler rejection tests (POSIX + Windows-gated); validated on a real
Windows 11 VM.
Closes#230
Drop the no-op try/catch around insertNode.run, and lowercase the Windows
SENSITIVE_PATHS entries so validateProjectPath's case-insensitive check
actually blocks c:\windows. Adds a validateProjectPath test (POSIX +
Windows-gated); the Windows-gated case was validated on a real Windows 11 VM.
Closes#327
Route handleContext's output through the shared truncateOutput cap
(MAX_OUTPUT_LENGTH) so codegraph_context can no longer blow past the context
budget — every sibling MCP tool already truncates; this was the one uncapped
output path.
Closes#296
Co-authored-by: Baijack-star <71923891+Baijack-star@users.noreply.github.com>
Remove .codegraph/config.json and the entire config surface. CodeGraph now
indexes every file whose extension maps to a supported language and respects
.gitignore everywhere — git repos via git itself, non-git projects via the
`ignore` library (root + nested .gitignore files, the same way git does).
- Remove CodeGraphConfig/DEFAULT_CONFIG, src/config.ts, and the public config
API (the `config` option on init, getConfig/updateConfig/getConfigPath).
- Derive the source-file allowlist from EXTENSION_MAP (isSourceFile); maxFileSize
is now a constant. Drop the .codegraphignore marker.
- Behavior change: committed, non-gitignored dirs (vendor/, a committed dist/)
are now indexed — .gitignore is the single source of truth.
Earlier inert fields (languages, frameworks, extractDocstrings, trackCallSites,
customPatterns) and their dead helpers are removed as part of this.
Resolves#283.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`markSessionConsulted` writes `${tmpdir()}/codegraph-consulted-${hash}` on
every `codegraph_context` call so external tooling can detect that an MCP
session has consulted CodeGraph. The old `writeFileSync` followed symlinks
unconditionally, so on a multi-user system any other local user could
pre-create that marker path as a symlink pointing at a victim-writable
file — the next codegraph context call would then overwrite the target's
contents with the ISO timestamp string (CWE-59).
The session-id hash gates predictability and makes opportunistic exploit
infeasible on its own, but tmpdir() is world-writable (mode 1777 on Linux)
and the proper pattern is to never follow links into a shared-prefix
tmpfile. Switch to `openSync` with O_NOFOLLOW + mode 0o600. ELOOP from a
planted symlink lands in the existing silent-fail catch — refuse to write
rather than touch an attacker-chosen target.
Detected by Aeon + manual review.
Severity: medium
CWE-59 (link following), CWE-732 (incorrect permission for critical resource)
Co-authored-by: aaronjmars <aaron@aeon.local>
- Add validateProjectPath() to reject sensitive system directories
- Add isPathWithinRoot/isPathWithinRootReal for symlink-aware path checks
- Replace hand-rolled glob-to-regex with picomatch to prevent ReDoS
- Add isSafeRegex() to reject custom patterns with nested quantifiers
- Replace FileLock with PID-tracking version that detects stale locks
- Add symlink detection in removeDirectory/listDirectoryContents
- Add subdirectory name validation in ensureSubdirectory
- Add atomicWriteFileSync and corrupted file backup in config-writer
- Add MCP input validation (validateString) for all tool handlers
- Fix CLAUDE.md section replacement to handle ### subsections correctly