/ src / memdir / paths.ts
paths.ts
  1  import memoize from 'lodash-es/memoize.js'
  2  import { homedir } from 'os'
  3  import { isAbsolute, join, normalize, sep } from 'path'
  4  import {
  5    getIsNonInteractiveSession,
  6    getProjectRoot,
  7  } from '../bootstrap/state.js'
  8  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
  9  import {
 10    getClaudeConfigHomeDir,
 11    isEnvDefinedFalsy,
 12    isEnvTruthy,
 13  } from '../utils/envUtils.js'
 14  import { findCanonicalGitRoot } from '../utils/git.js'
 15  import { sanitizePath } from '../utils/path.js'
 16  import {
 17    getInitialSettings,
 18    getSettingsForSource,
 19  } from '../utils/settings/settings.js'
 20  
 21  /**
 22   * Whether auto-memory features are enabled (memdir, agent memory, past session search).
 23   * Enabled by default. Priority chain (first defined wins):
 24   *   1. CLAUDE_CODE_DISABLE_AUTO_MEMORY env var (1/true → OFF, 0/false → ON)
 25   *   2. CLAUDE_CODE_SIMPLE (--bare) → OFF
 26   *   3. CCR without persistent storage → OFF (no CLAUDE_CODE_REMOTE_MEMORY_DIR)
 27   *   4. autoMemoryEnabled in settings.json (supports project-level opt-out)
 28   *   5. Default: enabled
 29   */
 30  export function isAutoMemoryEnabled(): boolean {
 31    const envVal = process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY
 32    if (isEnvTruthy(envVal)) {
 33      return false
 34    }
 35    if (isEnvDefinedFalsy(envVal)) {
 36      return true
 37    }
 38    // --bare / SIMPLE: prompts.ts already drops the memory section from the
 39    // system prompt via its SIMPLE early-return; this gate stops the other half
 40    // (extractMemories turn-end fork, autoDream, /remember, /dream, team sync).
 41    if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
 42      return false
 43    }
 44    if (
 45      isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) &&
 46      !process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR
 47    ) {
 48      return false
 49    }
 50    const settings = getInitialSettings()
 51    if (settings.autoMemoryEnabled !== undefined) {
 52      return settings.autoMemoryEnabled
 53    }
 54    return true
 55  }
 56  
 57  /**
 58   * Whether the extract-memories background agent will run this session.
 59   *
 60   * The main agent's prompt always has full save instructions regardless of
 61   * this gate — when the main agent writes memories, the background agent
 62   * skips that range (hasMemoryWritesSince in extractMemories.ts); when it
 63   * doesn't, the background agent catches anything missed.
 64   *
 65   * Callers must also gate on feature('EXTRACT_MEMORIES') — that check cannot
 66   * live inside this helper because feature() only tree-shakes when used
 67   * directly in an `if` condition.
 68   */
 69  export function isExtractModeActive(): boolean {
 70    if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)) {
 71      return false
 72    }
 73    return (
 74      !getIsNonInteractiveSession() ||
 75      getFeatureValue_CACHED_MAY_BE_STALE('tengu_slate_thimble', false)
 76    )
 77  }
 78  
 79  /**
 80   * Returns the base directory for persistent memory storage.
 81   * Resolution order:
 82   *   1. CLAUDE_CODE_REMOTE_MEMORY_DIR env var (explicit override, set in CCR)
 83   *   2. ~/.claude (default config home)
 84   */
 85  export function getMemoryBaseDir(): string {
 86    if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) {
 87      return process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR
 88    }
 89    return getClaudeConfigHomeDir()
 90  }
 91  
 92  const AUTO_MEM_DIRNAME = 'memory'
 93  const AUTO_MEM_ENTRYPOINT_NAME = 'MEMORY.md'
 94  
 95  /**
 96   * Normalize and validate a candidate auto-memory directory path.
 97   *
 98   * SECURITY: Rejects paths that would be dangerous as a read-allowlist root
 99   * or that normalize() doesn't fully resolve:
100   * - relative (!isAbsolute): "../foo" — would be interpreted relative to CWD
101   * - root/near-root (length < 3): "/" → "" after strip; "/a" too short
102   * - Windows drive-root (C: regex): "C:\" → "C:" after strip
103   * - UNC paths (\\server\share): network paths — opaque trust boundary
104   * - null byte: survives normalize(), can truncate in syscalls
105   *
106   * Returns the normalized path with exactly one trailing separator,
107   * or undefined if the path is unset/empty/rejected.
108   */
109  function validateMemoryPath(
110    raw: string | undefined,
111    expandTilde: boolean,
112  ): string | undefined {
113    if (!raw) {
114      return undefined
115    }
116    let candidate = raw
117    // Settings.json paths support ~/ expansion (user-friendly). The env var
118    // override does not (it's set programmatically by Cowork/SDK, which should
119    // always pass absolute paths). Bare "~", "~/", "~/.", "~/..", etc. are NOT
120    // expanded — they would make isAutoMemPath() match all of $HOME or its
121    // parent (same class of danger as "/" or "C:\").
122    if (
123      expandTilde &&
124      (candidate.startsWith('~/') || candidate.startsWith('~\\'))
125    ) {
126      const rest = candidate.slice(2)
127      // Reject trivial remainders that would expand to $HOME or an ancestor.
128      // normalize('') = '.', normalize('.') = '.', normalize('foo/..') = '.',
129      // normalize('..') = '..', normalize('foo/../..') = '..'
130      const restNorm = normalize(rest || '.')
131      if (restNorm === '.' || restNorm === '..') {
132        return undefined
133      }
134      candidate = join(homedir(), rest)
135    }
136    // normalize() may preserve a trailing separator; strip before adding
137    // exactly one to match the trailing-sep contract of getAutoMemPath()
138    const normalized = normalize(candidate).replace(/[/\\]+$/, '')
139    if (
140      !isAbsolute(normalized) ||
141      normalized.length < 3 ||
142      /^[A-Za-z]:$/.test(normalized) ||
143      normalized.startsWith('\\\\') ||
144      normalized.startsWith('//') ||
145      normalized.includes('\0')
146    ) {
147      return undefined
148    }
149    return (normalized + sep).normalize('NFC')
150  }
151  
152  /**
153   * Direct override for the full auto-memory directory path via env var.
154   * When set, getAutoMemPath()/getAutoMemEntrypoint() return this path directly
155   * instead of computing `{base}/projects/{sanitized-cwd}/memory/`.
156   *
157   * Used by Cowork to redirect memory to a space-scoped mount where the
158   * per-session cwd (which contains the VM process name) would otherwise
159   * produce a different project-key for every session.
160   */
161  function getAutoMemPathOverride(): string | undefined {
162    return validateMemoryPath(
163      process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE,
164      false,
165    )
166  }
167  
168  /**
169   * Settings.json override for the full auto-memory directory path.
170   * Supports ~/ expansion for user convenience.
171   *
172   * SECURITY: projectSettings (.claude/settings.json committed to the repo) is
173   * intentionally excluded — a malicious repo could otherwise set
174   * autoMemoryDirectory: "~/.ssh" and gain silent write access to sensitive
175   * directories via the filesystem.ts write carve-out (which fires when
176   * isAutoMemPath() matches and hasAutoMemPathOverride() is false). This follows
177   * the same pattern as hasSkipDangerousModePermissionPrompt() etc.
178   */
179  function getAutoMemPathSetting(): string | undefined {
180    const dir =
181      getSettingsForSource('policySettings')?.autoMemoryDirectory ??
182      getSettingsForSource('flagSettings')?.autoMemoryDirectory ??
183      getSettingsForSource('localSettings')?.autoMemoryDirectory ??
184      getSettingsForSource('userSettings')?.autoMemoryDirectory
185    return validateMemoryPath(dir, true)
186  }
187  
188  /**
189   * Check if CLAUDE_COWORK_MEMORY_PATH_OVERRIDE is set to a valid override.
190   * Use this as a signal that the SDK caller has explicitly opted into
191   * the auto-memory mechanics — e.g. to decide whether to inject the
192   * memory prompt when a custom system prompt replaces the default.
193   */
194  export function hasAutoMemPathOverride(): boolean {
195    return getAutoMemPathOverride() !== undefined
196  }
197  
198  /**
199   * Returns the canonical git repo root if available, otherwise falls back to
200   * the stable project root. Uses findCanonicalGitRoot so all worktrees of the
201   * same repo share one auto-memory directory (anthropics/claude-code#24382).
202   */
203  function getAutoMemBase(): string {
204    return findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot()
205  }
206  
207  /**
208   * Returns the auto-memory directory path.
209   *
210   * Resolution order:
211   *   1. CLAUDE_COWORK_MEMORY_PATH_OVERRIDE env var (full-path override, used by Cowork)
212   *   2. autoMemoryDirectory in settings.json (trusted sources only: policy/local/user)
213   *   3. <memoryBase>/projects/<sanitized-git-root>/memory/
214   *      where memoryBase is resolved by getMemoryBaseDir()
215   *
216   * Memoized: render-path callers (collapseReadSearchGroups → isAutoManagedMemoryFile)
217   * fire per tool-use message per Messages re-render; each miss costs
218   * getSettingsForSource × 4 → parseSettingsFile (realpathSync + readFileSync).
219   * Keyed on projectRoot so tests that change its mock mid-block recompute;
220   * env vars / settings.json / CLAUDE_CONFIG_DIR are session-stable in
221   * production and covered by per-test cache.clear.
222   */
223  export const getAutoMemPath = memoize(
224    (): string => {
225      const override = getAutoMemPathOverride() ?? getAutoMemPathSetting()
226      if (override) {
227        return override
228      }
229      const projectsDir = join(getMemoryBaseDir(), 'projects')
230      return (
231        join(projectsDir, sanitizePath(getAutoMemBase()), AUTO_MEM_DIRNAME) + sep
232      ).normalize('NFC')
233    },
234    () => getProjectRoot(),
235  )
236  
237  /**
238   * Returns the daily log file path for the given date (defaults to today).
239   * Shape: <autoMemPath>/logs/YYYY/MM/YYYY-MM-DD.md
240   *
241   * Used by assistant mode (feature('KAIROS')): rather than maintaining
242   * MEMORY.md as a live index, the agent appends to a date-named log file
243   * as it works. A separate nightly /dream skill distills these logs into
244   * topic files + MEMORY.md.
245   */
246  export function getAutoMemDailyLogPath(date: Date = new Date()): string {
247    const yyyy = date.getFullYear().toString()
248    const mm = (date.getMonth() + 1).toString().padStart(2, '0')
249    const dd = date.getDate().toString().padStart(2, '0')
250    return join(getAutoMemPath(), 'logs', yyyy, mm, `${yyyy}-${mm}-${dd}.md`)
251  }
252  
253  /**
254   * Returns the auto-memory entrypoint (MEMORY.md inside the auto-memory dir).
255   * Follows the same resolution order as getAutoMemPath().
256   */
257  export function getAutoMemEntrypoint(): string {
258    return join(getAutoMemPath(), AUTO_MEM_ENTRYPOINT_NAME)
259  }
260  
261  /**
262   * Check if an absolute path is within the auto-memory directory.
263   *
264   * When CLAUDE_COWORK_MEMORY_PATH_OVERRIDE is set, this matches against the
265   * env-var override directory. Note that a true return here does NOT imply
266   * write permission in that case — the filesystem.ts write carve-out is gated
267   * on !hasAutoMemPathOverride() (it exists to bypass DANGEROUS_DIRECTORIES).
268   *
269   * The settings.json autoMemoryDirectory DOES get the write carve-out: it's the
270   * user's explicit choice from a trusted settings source (projectSettings is
271   * excluded — see getAutoMemPathSetting), and hasAutoMemPathOverride() remains
272   * false for it.
273   */
274  export function isAutoMemPath(absolutePath: string): boolean {
275    // SECURITY: Normalize to prevent path traversal bypasses via .. segments
276    const normalizedPath = normalize(absolutePath)
277    return normalizedPath.startsWith(getAutoMemPath())
278  }