Skip to content

Claude Code: directoryBankMap silently ignores a mapped directory reached via a symlink (normpath, not realpath) #2312

@andb

Description

@andb

Disclosed transparently: found by an AI agent (Claude Opus 4.8) via a high-effort, adversarial multi-agent verification workflow, and filed as AI-discovered on purpose. It's checked against your actual code — a runnable repro and a failing test are below, and the exact verification steps are in the collapsed section at the end. Please judge it on the repro.

Bug Description

In the Claude Code integration, directoryBankMap entries are matched against the session cwd with a comparison that does not resolve symlinks, so a directory and a symlink to that same directory are treated as different paths. The result: an explicit user-configured mapping silently fails to apply and session memory is routed to the wrong bank — with no error or log.

hindsight-integrations/claude-code/scripts/lib/bank.py, derive_bank_id() (~L98–100 on main):

normalized_cwd = os.path.normcase(os.path.normpath(cwd))
for dir_path, bank_id in dir_map.items():
    if os.path.normcase(os.path.normpath(dir_path)) == normalized_cwd:
        return ...

os.path.normpath does not resolve symlinks (only os.path.realpath does). When the mapped directory and the live cwd are the same directory reached by two different path strings — the mapped dir is itself a symlink, sits under a symlinked mount (e.g. macOS /var/private/var), or the map and cwd simply disagree on symlink-vs-real form — the entry does not match and the session falls through to the static/dynamic bank.

Steps to Reproduce

import sys, os, tempfile
sys.path.insert(0, "hindsight-integrations/claude-code/scripts")
from lib.bank import derive_bank_id

base = tempfile.mkdtemp()
real = os.path.realpath(os.path.join(base, "proj")); os.makedirs(real)  # canonical, immune to /var symlinks
link = os.path.join(base, "proj-link"); os.symlink(real, link)          # SAME dir, different path string
assert os.path.realpath(link) == real

cfg = {"directoryBankMap": {real: "myproj"}, "bankId": "fallback"}
print(derive_bank_id({"cwd": real}, cfg))   # -> myproj
print(derive_bank_id({"cwd": link}, cfg))   # -> fallback   (expected: myproj)

(realpath on the map key matters on macOS, where mkdtemp lives under the /var/private/var symlink; without it the control case can mask the effect.)

Expected Behavior

A cwd that resolves to a mapped directory matches the mapping. directoryBankMap keys identify directories, and a directory reached via a symlink is the same directory.

Actual Behavior

Only a path string that is (normcase/normpath-)identical to the key matches; a symlinked path to the same directory falls through to the fallback bank. The miss is silent — no error, no log — so memory is written to and recalled from the wrong bank without any signal.

Version

Confirmed on main (reads as v0.8.3, June 2026); also present in 0.7.1. Independent of LLM provider (this is path handling).


Proposed fix

Resolve both sides before comparing:

normalized_cwd = os.path.normcase(os.path.realpath(cwd))
... os.path.normcase(os.path.realpath(dir_path)) == normalized_cwd ...

Compatibility: realpath changes matching for anyone relying on the literal (unresolved) path — worth a changelog line. (Resolving is the behavior the original 0.7.1 comment described.)

Secondary, minor (same root cause)

_resolve_project_name() uses os.path.basename(cwd) on the raw cwd in the non-git fallback (and when resolveWorktrees: false), so a non-git directory reached via a symlink derives a different project/bank than its real path. Git repos are unaffected — git rev-parse --path-format=absolute --git-common-dir canonicalizes (verified). The codex/cline integrations also derive project from os.path.basename(cwd), but they have no directoryBankMap, so the primary issue above is Claude-Code-specific.

Suggested regression test

Fails on current main (symlinked cwd returns the fallback); passes with the realpath fix.

import os, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
from lib.bank import derive_bank_id

def test_directorybankmap_matches_symlinked_cwd(tmp_path):
    real = os.path.realpath(tmp_path / "proj"); os.makedirs(real)
    link = os.path.join(str(tmp_path), "proj-link"); os.symlink(real, link)
    cfg = {"directoryBankMap": {real: "myproj"}, "bankId": "fallback"}
    assert derive_bank_id({"cwd": real}, cfg) == "myproj"
    assert derive_bank_id({"cwd": link}, cfg) == "myproj"  # currently returns "fallback"

On intent

On main there is no comment-vs-code contradiction. But in 0.7.1 the inline comment on this block read "Normalize cwd for matching (resolve symlinks, trailing slashes)" — evidence the original author believed symlink resolution was happening, so the missing realpath reads as an oversight rather than a deliberate decision. (#2183 later rewrote that comment while adding Windows normcase; the "resolve symlinks" phrase was dropped, but that change was about case-folding, not a decision about symlink handling.)

How this was found and verified (AI provenance — for transparency)

Found by Claude Opus 4.8 (Anthropic) running a high-effort, adversarial multi-agent verification workflow: each substantive claim was challenged by independent reviewer passes before it was allowed into this report, specifically to guard against a hallucinated or overstated finding.

  1. Reproduced against the real code — imported and called the actual derive_bank_id() (not a re-implementation) with a symlinked cwd; it returned the fallback bank.
  2. Confirmed on the latest release — read the current main (v0.8.3) source; the match is still normcase(normpath(...)), no realpath.
  3. Empirically scoped the secondary case — created a symlinked git repo and confirmed git rev-parse --git-common-dir canonicalizes, so git repos are not affected (avoiding an overclaim).
  4. Traced provenance — read the introducing PR (feat(claude-code): resolve git worktrees + explicit directory-bank mapping #1520) and confirmed directoryBankMap was intentionally exact-match, so this is reported as missing symlink resolution, not a false "regression."
  5. Dup-checked open + closed issues and PRs, and checked the other integrations (codex/cline/aider), scoping the report to where the feature actually lives.

Two initially-suspected stronger angles (a git-worktree regression; a cross-integration systemic pattern) were rejected during verification as overclaims — so what remains above is only what survived adversarial review. Everything is independently checkable via the repro and the cited lines.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions