import memoize from 'lodash-es/memoize.js' import { homedir } from 'os' import { isAbsolute, join, normalize, sep } from 'path' import { getIsNonInteractiveSession, getProjectRoot, } from '../bootstrap/state.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' import { getClaudeConfigHomeDir, isEnvDefinedFalsy, isEnvTruthy, } from '../utils/envUtils.js' import { findCanonicalGitRoot } from '../utils/git.js' import { sanitizePath } from '../utils/path.js' import { getInitialSettings, getSettingsForSource, } from '../utils/settings/settings.js' /** * Whether auto-memory features are enabled (memdir, agent memory, past session search). * Enabled by default. Priority chain (first defined wins): * 1. CLAUDE_CODE_DISABLE_AUTO_MEMORY env var (1/true → OFF, 0/false → ON) * 2. CLAUDE_CODE_SIMPLE (--bare) → OFF * 3. CCR without persistent storage → OFF (no CLAUDE_CODE_REMOTE_MEMORY_DIR) * 4. autoMemoryEnabled in settings.json (supports project-level opt-out) * 5. Default: enabled */ export function isAutoMemoryEnabled(): boolean { const envVal = process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY if (isEnvTruthy(envVal)) { return false } if (isEnvDefinedFalsy(envVal)) { return true } // --bare / SIMPLE: prompts.ts already drops the memory section from the // system prompt via its SIMPLE early-return; this gate stops the other half // (extractMemories turn-end fork, autoDream, /remember, /dream, team sync). if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { return false } if ( isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && !process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR ) { return false } const settings = getInitialSettings() if (settings.autoMemoryEnabled !== undefined) { return settings.autoMemoryEnabled } return true } /** * Whether the extract-memories background agent will run this session. * * The main agent's prompt always has full save instructions regardless of * this gate — when the main agent writes memories, the background agent * skips that range (hasMemoryWritesSince in extractMemories.ts); when it * doesn't, the background agent catches anything missed. * * Callers must also gate on feature('EXTRACT_MEMORIES') — that check cannot * live inside this helper because feature() only tree-shakes when used * directly in an `if` condition. */ export function isExtractModeActive(): boolean { if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)) { return false } return ( !getIsNonInteractiveSession() || getFeatureValue_CACHED_MAY_BE_STALE('tengu_slate_thimble', false) ) } /** * Returns the base directory for persistent memory storage. * Resolution order: * 1. CLAUDE_CODE_REMOTE_MEMORY_DIR env var (explicit override, set in CCR) * 2. ~/.claude (default config home) */ export function getMemoryBaseDir(): string { if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) { return process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR } return getClaudeConfigHomeDir() } const AUTO_MEM_DIRNAME = 'memory' const AUTO_MEM_ENTRYPOINT_NAME = 'MEMORY.md' /** * Normalize and validate a candidate auto-memory directory path. * * SECURITY: Rejects paths that would be dangerous as a read-allowlist root * or that normalize() doesn't fully resolve: * - relative (!isAbsolute): "../foo" — would be interpreted relative to CWD * - root/near-root (length < 3): "/" → "" after strip; "/a" too short * - Windows drive-root (C: regex): "C:\" → "C:" after strip * - UNC paths (\\server\share): network paths — opaque trust boundary * - null byte: survives normalize(), can truncate in syscalls * * Returns the normalized path with exactly one trailing separator, * or undefined if the path is unset/empty/rejected. */ function validateMemoryPath( raw: string | undefined, expandTilde: boolean, ): string | undefined { if (!raw) { return undefined } let candidate = raw // Settings.json paths support ~/ expansion (user-friendly). The env var // override does not (it's set programmatically by Cowork/SDK, which should // always pass absolute paths). Bare "~", "~/", "~/.", "~/..", etc. are NOT // expanded — they would make isAutoMemPath() match all of $HOME or its // parent (same class of danger as "/" or "C:\"). if ( expandTilde && (candidate.startsWith('~/') || candidate.startsWith('~\\')) ) { const rest = candidate.slice(2) // Reject trivial remainders that would expand to $HOME or an ancestor. // normalize('') = '.', normalize('.') = '.', normalize('foo/..') = '.', // normalize('..') = '..', normalize('foo/../..') = '..' const restNorm = normalize(rest || '.') if (restNorm === '.' || restNorm === '..') { return undefined } candidate = join(homedir(), rest) } // normalize() may preserve a trailing separator; strip before adding // exactly one to match the trailing-sep contract of getAutoMemPath() const normalized = normalize(candidate).replace(/[/\\]+$/, '') if ( !isAbsolute(normalized) || normalized.length < 3 || /^[A-Za-z]:$/.test(normalized) || normalized.startsWith('\\\\') || normalized.startsWith('//') || normalized.includes('\0') ) { return undefined } return (normalized + sep).normalize('NFC') } /** * Direct override for the full auto-memory directory path via env var. * When set, getAutoMemPath()/getAutoMemEntrypoint() return this path directly * instead of computing `{base}/projects/{sanitized-cwd}/memory/`. * * Used by Cowork to redirect memory to a space-scoped mount where the * per-session cwd (which contains the VM process name) would otherwise * produce a different project-key for every session. */ function getAutoMemPathOverride(): string | undefined { return validateMemoryPath( process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE, false, ) } /** * Settings.json override for the full auto-memory directory path. * Supports ~/ expansion for user convenience. * * SECURITY: projectSettings (.claude/settings.json committed to the repo) is * intentionally excluded — a malicious repo could otherwise set * autoMemoryDirectory: "~/.ssh" and gain silent write access to sensitive * directories via the filesystem.ts write carve-out (which fires when * isAutoMemPath() matches and hasAutoMemPathOverride() is false). This follows * the same pattern as hasSkipDangerousModePermissionPrompt() etc. */ function getAutoMemPathSetting(): string | undefined { const dir = getSettingsForSource('policySettings')?.autoMemoryDirectory ?? getSettingsForSource('flagSettings')?.autoMemoryDirectory ?? getSettingsForSource('localSettings')?.autoMemoryDirectory ?? getSettingsForSource('userSettings')?.autoMemoryDirectory return validateMemoryPath(dir, true) } /** * Check if CLAUDE_COWORK_MEMORY_PATH_OVERRIDE is set to a valid override. * Use this as a signal that the SDK caller has explicitly opted into * the auto-memory mechanics — e.g. to decide whether to inject the * memory prompt when a custom system prompt replaces the default. */ export function hasAutoMemPathOverride(): boolean { return getAutoMemPathOverride() !== undefined } /** * Returns the canonical git repo root if available, otherwise falls back to * the stable project root. Uses findCanonicalGitRoot so all worktrees of the * same repo share one auto-memory directory (anthropics/claude-code#24382). */ function getAutoMemBase(): string { return findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot() } /** * Returns the auto-memory directory path. * * Resolution order: * 1. CLAUDE_COWORK_MEMORY_PATH_OVERRIDE env var (full-path override, used by Cowork) * 2. autoMemoryDirectory in settings.json (trusted sources only: policy/local/user) * 3. /projects//memory/ * where memoryBase is resolved by getMemoryBaseDir() * * Memoized: render-path callers (collapseReadSearchGroups → isAutoManagedMemoryFile) * fire per tool-use message per Messages re-render; each miss costs * getSettingsForSource × 4 → parseSettingsFile (realpathSync + readFileSync). * Keyed on projectRoot so tests that change its mock mid-block recompute; * env vars / settings.json / CLAUDE_CONFIG_DIR are session-stable in * production and covered by per-test cache.clear. */ export const getAutoMemPath = memoize( (): string => { const override = getAutoMemPathOverride() ?? getAutoMemPathSetting() if (override) { return override } const projectsDir = join(getMemoryBaseDir(), 'projects') return ( join(projectsDir, sanitizePath(getAutoMemBase()), AUTO_MEM_DIRNAME) + sep ).normalize('NFC') }, () => getProjectRoot(), ) /** * Returns the daily log file path for the given date (defaults to today). * Shape: /logs/YYYY/MM/YYYY-MM-DD.md * * Used by assistant mode (feature('KAIROS')): rather than maintaining * MEMORY.md as a live index, the agent appends to a date-named log file * as it works. A separate nightly /dream skill distills these logs into * topic files + MEMORY.md. */ export function getAutoMemDailyLogPath(date: Date = new Date()): string { const yyyy = date.getFullYear().toString() const mm = (date.getMonth() + 1).toString().padStart(2, '0') const dd = date.getDate().toString().padStart(2, '0') return join(getAutoMemPath(), 'logs', yyyy, mm, `${yyyy}-${mm}-${dd}.md`) } /** * Returns the auto-memory entrypoint (MEMORY.md inside the auto-memory dir). * Follows the same resolution order as getAutoMemPath(). */ export function getAutoMemEntrypoint(): string { return join(getAutoMemPath(), AUTO_MEM_ENTRYPOINT_NAME) } /** * Check if an absolute path is within the auto-memory directory. * * When CLAUDE_COWORK_MEMORY_PATH_OVERRIDE is set, this matches against the * env-var override directory. Note that a true return here does NOT imply * write permission in that case — the filesystem.ts write carve-out is gated * on !hasAutoMemPathOverride() (it exists to bypass DANGEROUS_DIRECTORIES). * * The settings.json autoMemoryDirectory DOES get the write carve-out: it's the * user's explicit choice from a trusted settings source (projectSettings is * excluded — see getAutoMemPathSetting), and hasAutoMemPathOverride() remains * false for it. */ export function isAutoMemPath(absolutePath: string): boolean { // SECURITY: Normalize to prevent path traversal bypasses via .. segments const normalizedPath = normalize(absolutePath) return normalizedPath.startsWith(getAutoMemPath()) }