From 00d73fbae241bbea1190f16a1f3afd7b19724de3 Mon Sep 17 00:00:00 2001 From: basil-k-aji-dev <70605804+basil-k-aji-dev@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:34:14 +0530 Subject: [PATCH] fix(hooks): honor CLAUDE_CONFIG_DIR instead of writing to ~/.claude Claude Code points every profile-local path at CLAUDE_CONFIG_DIR when it is set, but ECC hooks kept reading and writing ~/.claude. A session launched under a second profile left its bash log, cost tracker, metrics, session data and skill runs in the default profile, where the other one reads them back. Two groups, as reported: - getDefaultClaudeAgentDataHome() in scripts/lib/agent-data-home.js now follows the variable, which covers every hook reaching ~/.claude through resolveAgentDataHome(). ECC_AGENT_DATA_HOME still wins, so the documented workaround keeps working. - plan-canvas-pending, plan-canvas-sessions, mcp-health-check and post-bash-command-log hard-coded os.homedir()/.claude and now resolve the same way, matching scripts/hooks/ecc-statusline.js:68. A blank value is ignored rather than resolved, since path.resolve(' ') returns the process cwd and would scatter profile data through whatever directory the session started in. Fixes #3266 --- scripts/hooks/mcp-health-check.js | 12 ++++++- scripts/hooks/plan-canvas-pending.js | 12 ++++++- scripts/hooks/plan-canvas-sessions.js | 12 ++++++- scripts/hooks/post-bash-command-log.js | 12 ++++++- scripts/lib/agent-data-home.js | 11 +++++++ tests/lib/agent-data-home.test.js | 44 ++++++++++++++++++++++++++ 6 files changed, 99 insertions(+), 4 deletions(-) diff --git a/scripts/hooks/mcp-health-check.js b/scripts/hooks/mcp-health-check.js index 843a6ec04..adcc11540 100644 --- a/scripts/hooks/mcp-health-check.js +++ b/scripts/hooks/mcp-health-check.js @@ -47,11 +47,21 @@ function envNumber(name, fallback) { return Number.isFinite(value) && value >= 0 ? value : fallback; } +// Claude Code redirects every profile-local path when CLAUDE_CONFIG_DIR is set. +// Writing to ~/.claude regardless leaks this profile's data into the default one. +function claudeConfigDir() { + const configured = process.env.CLAUDE_CONFIG_DIR; + if (configured && String(configured).trim().length > 0) { + return path.resolve(String(configured).trim()); + } + return path.join(os.homedir(), '.claude'); +} + function stateFilePath() { if (process.env.ECC_MCP_HEALTH_STATE_PATH) { return path.resolve(process.env.ECC_MCP_HEALTH_STATE_PATH); } - return path.join(os.homedir(), '.claude', 'mcp-health-cache.json'); + return path.join(claudeConfigDir(), 'mcp-health-cache.json'); } function configPaths() { diff --git a/scripts/hooks/plan-canvas-pending.js b/scripts/hooks/plan-canvas-pending.js index 9ba2a9207..4e97bddda 100644 --- a/scripts/hooks/plan-canvas-pending.js +++ b/scripts/hooks/plan-canvas-pending.js @@ -34,10 +34,20 @@ const path = require('path'); const SERVER_TIMEOUT_MS = 1000; const MAX_ITEMS_REPORTED = 20; +// Claude Code redirects every profile-local path when CLAUDE_CONFIG_DIR is set. +// Writing to ~/.claude regardless leaks this profile's data into the default one. +function claudeConfigDir() { + const configured = process.env.CLAUDE_CONFIG_DIR; + if (configured && String(configured).trim().length > 0) { + return path.resolve(String(configured).trim()); + } + return path.join(os.homedir(), '.claude'); +} + function stateDir() { const override = process.env.ECC_PLAN_CANVAS_STATE_DIR; if (override && override.trim()) return path.resolve(override.trim()); - return path.join(os.homedir(), '.claude', 'plan-canvas'); + return path.join(claudeConfigDir(), 'plan-canvas'); } function readState() { diff --git a/scripts/hooks/plan-canvas-sessions.js b/scripts/hooks/plan-canvas-sessions.js index 4e0799f87..009d68a69 100644 --- a/scripts/hooks/plan-canvas-sessions.js +++ b/scripts/hooks/plan-canvas-sessions.js @@ -19,10 +19,20 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +// Claude Code redirects every profile-local path when CLAUDE_CONFIG_DIR is set. +// Writing to ~/.claude regardless leaks this profile's data into the default one. +function claudeConfigDir() { + const configured = process.env.CLAUDE_CONFIG_DIR; + if (configured && String(configured).trim().length > 0) { + return path.resolve(String(configured).trim()); + } + return path.join(os.homedir(), '.claude'); +} + function stateDir() { const override = process.env.ECC_PLAN_CANVAS_STATE_DIR; if (override && override.trim()) return path.resolve(override.trim()); - return path.join(os.homedir(), '.claude', 'plan-canvas'); + return path.join(claudeConfigDir(), 'plan-canvas'); } function openSessions() { diff --git a/scripts/hooks/post-bash-command-log.js b/scripts/hooks/post-bash-command-log.js index bdea239df..41945b115 100644 --- a/scripts/hooks/post-bash-command-log.js +++ b/scripts/hooks/post-bash-command-log.js @@ -38,6 +38,16 @@ function appendLine(filePath, line) { fs.appendFileSync(filePath, `${line}\n`, 'utf8'); } +// Claude Code redirects every profile-local path when CLAUDE_CONFIG_DIR is set. +// Writing to ~/.claude regardless leaks this profile's data into the default one. +function claudeConfigDir() { + const configured = process.env.CLAUDE_CONFIG_DIR; + if (configured && String(configured).trim().length > 0) { + return path.resolve(String(configured).trim()); + } + return path.join(os.homedir(), '.claude'); +} + function run(rawInput, mode = 'audit') { const config = MODE_CONFIG[mode]; @@ -45,7 +55,7 @@ function run(rawInput, mode = 'audit') { if (config) { const input = String(rawInput || '').trim() ? JSON.parse(String(rawInput)) : {}; const command = sanitizeCommand(input.tool_input?.command || '?'); - appendLine(path.join(os.homedir(), '.claude', config.fileName), config.format(command)); + appendLine(path.join(claudeConfigDir(), config.fileName), config.format(command)); } } catch { // Logging must never block the calling hook. diff --git a/scripts/lib/agent-data-home.js b/scripts/lib/agent-data-home.js index 7302bd572..8362c31b3 100644 --- a/scripts/lib/agent-data-home.js +++ b/scripts/lib/agent-data-home.js @@ -17,6 +17,7 @@ const path = require('path'); const { assertWithinTrustedRoot } = require('./path-safety'); const AGENT_DATA_HOME_ENV = 'ECC_AGENT_DATA_HOME'; +const CLAUDE_CONFIG_DIR_ENV = 'CLAUDE_CONFIG_DIR'; const DEFAULT_CLAUDE_DIR_NAME = '.claude'; const DEFAULT_CURSOR_ECC_DIR_SEGMENTS = ['.cursor', 'ecc']; const PROJECT_CONFIG_RELATIVE = path.join('.cursor', 'ecc-agent-data.json'); @@ -92,6 +93,15 @@ function getDefaultCursorAgentDataHome() { } function getDefaultClaudeAgentDataHome() { + // Claude Code points every profile-local path at CLAUDE_CONFIG_DIR when it is + // set, so a session launched under a second profile must not keep reading and + // writing the default ~/.claude. This moves the trusted root with it, which is + // the same trust ECC_AGENT_DATA_HOME already gets: both come from whoever + // launched the session, not from the project. + const configDir = process.env[CLAUDE_CONFIG_DIR_ENV]; + if (configDir && String(configDir).trim().length > 0) { + return expandHomePath(String(configDir).trim(), process.cwd()); + } return path.join(getHomeDirFromEnv(), DEFAULT_CLAUDE_DIR_NAME); } @@ -228,6 +238,7 @@ function getCursorSessionEnvPayload(options = {}) { module.exports = { AGENT_DATA_HOME_ENV, + CLAUDE_CONFIG_DIR_ENV, DEFAULT_CLAUDE_DIR_NAME, DEFAULT_CURSOR_ECC_DIR_SEGMENTS, PROJECT_CONFIG_RELATIVE, diff --git a/tests/lib/agent-data-home.test.js b/tests/lib/agent-data-home.test.js index f5f72fc35..8aeac883b 100644 --- a/tests/lib/agent-data-home.test.js +++ b/tests/lib/agent-data-home.test.js @@ -497,6 +497,50 @@ function runTests() { }); })) passed++; else failed++; + if (test('getDefaultClaudeAgentDataHome follows CLAUDE_CONFIG_DIR', () => { + // Claude Code points every profile-local path at CLAUDE_CONFIG_DIR. Ignoring + // it writes this profile's session data, logs and metrics into the default + // ~/.claude, where another profile reads them back. + withIsolatedCwd(() => { + withEnv({ + CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ecc-profile-x'), + ECC_AGENT_DATA_HOME: undefined, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual( + agentDataHome.getDefaultClaudeAgentDataHome(), + path.join(os.tmpdir(), 'ecc-profile-x') + ); + }); + }); + })) passed++; else failed++; + + if (test('getDefaultClaudeAgentDataHome falls back to ~/.claude without the env', () => { + withIsolatedCwd(() => { + withEnv({ CLAUDE_CONFIG_DIR: undefined, ECC_AGENT_DATA_HOME: undefined }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual( + agentDataHome.getDefaultClaudeAgentDataHome(), + path.join(os.homedir(), '.claude') + ); + }); + }); + })) passed++; else failed++; + + if (test('a blank CLAUDE_CONFIG_DIR is ignored rather than resolving to cwd', () => { + // An empty or whitespace value would otherwise resolve to the process cwd and + // scatter profile data through whatever directory the session started in. + withIsolatedCwd(() => { + withEnv({ CLAUDE_CONFIG_DIR: ' ', ECC_AGENT_DATA_HOME: undefined }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual( + agentDataHome.getDefaultClaudeAgentDataHome(), + path.join(os.homedir(), '.claude') + ); + }); + }); + })) passed++; else failed++; + console.log(`\n=== Test Results ===\nPassed: ${passed}\nFailed: ${failed}\n`); if (failed > 0) process.exit(1); }