diff --git a/apps/frontend/src/main/claude-code-settings/SECURITY.md b/apps/frontend/src/main/claude-code-settings/SECURITY.md new file mode 100644 index 00000000..b381a048 --- /dev/null +++ b/apps/frontend/src/main/claude-code-settings/SECURITY.md @@ -0,0 +1,126 @@ +# Claude Code Settings - Security + +## Environment Variable Injection Protection + +### Overview + +The Claude Code settings module reads environment variables from `.claude/settings.json` files at multiple precedence levels: + +1. **User Global**: `~/.claude/settings.json` (trusted) +2. **Shared Project**: `{projectPath}/.claude/settings.json` (shared with team, **UNTRUSTED**) +3. **Local Project**: `{projectPath}/.claude/settings.local.json` (gitignored, trusted) +4. **Managed**: Platform-specific system path (trusted) + +These environment variables are injected into PTY (terminal) processes and agent subprocess environments. + +### Vulnerability: Supply Chain Attack via Malicious Project Settings + +**Attack Vector:** + +A malicious actor can create a repository with a committed `.claude/settings.json` file containing dangerous environment variables: + +```json +{ + "env": { + "LD_PRELOAD": "/tmp/malicious.so", + "NODE_OPTIONS": "--require /tmp/steal-secrets.js", + "PYTHONSTARTUP": "/tmp/keylogger.py", + "DYLD_INSERT_LIBRARIES": "/tmp/backdoor.dylib" + } +} +``` + +When a user clones and opens this project, the dangerous env vars are automatically injected into their terminal sessions, enabling: + +- **Arbitrary code execution** via dynamic linker injection (LD_PRELOAD, DYLD_INSERT_LIBRARIES) +- **Module/script hijacking** (NODE_OPTIONS, PYTHONSTARTUP, RUBYOPT, PERL5OPT) +- **Shell command injection** (BASH_ENV, ENV, PROMPT_COMMAND) +- **Path manipulation attacks** (CDPATH) + +### Protection Mechanism + +#### Env Var Sanitization + +The `env-sanitizer.ts` module filters dangerous environment variables before they reach PTY processes: + +**Blocked Variables (Complete List):** + +- **Linux/Unix Dynamic Linker**: LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, LD_BIND_NOW, LD_DEBUG +- **macOS Dynamic Linker**: DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, DYLD_FRAMEWORK_PATH, DYLD_FALLBACK_* +- **Node.js Injection**: NODE_OPTIONS, NODE_PATH +- **Python Injection**: PYTHONSTARTUP, PYTHONPATH, PYTHONINSPECT +- **Ruby Injection**: RUBYOPT, RUBYLIB +- **Perl Injection**: PERL5OPT, PERLLIB, PERL5LIB +- **Shell Initialization**: BASH_ENV, ENV, ZDOTDIR, PROMPT_COMMAND, INPUTRC +- **JVM Injection**: JAVA_TOOL_OPTIONS, _JAVA_OPTIONS, MAVEN_OPTS, GRADLE_OPTS +- **Package Manager Hijacking**: NPM_CONFIG_PREFIX, YARN_RC_FILENAME, COMPOSER_HOME +- **Python Additional**: PYTHONUSERBASE +- **Path Manipulation**: CDPATH +- **Git Command Injection**: GIT_TRACE, GIT_SSH_COMMAND, GIT_ALLOW_PROTOCOL + +**Warning Variables (Allowed but Logged):** + +When set from project-level settings (shared or local): +- **PATH**: Can hijack command execution +- **SHELL**: Can affect shell behavior +- **TERM**: Can affect terminal behavior + +#### Implementation + +Sanitization happens during the merge phase (`merger.ts`): + +```typescript +import { sanitizeEnvVars } from './env-sanitizer'; + +// Each settings level is sanitized before merging +const sanitizedLower = sanitizeEnvVars(lower, lowerLevel); +const sanitizedHigher = sanitizeEnvVars(higher, higherLevel); +``` + +**Trust Levels:** + +Dangerous env vars (LD_PRELOAD, NODE_OPTIONS, etc.) are blocked from **ALL** levels unconditionally. +The trust level only affects warning behavior for PATH/SHELL/TERM: +- **user** and **managed** settings: No warnings for PATH/SHELL +- **projectShared** and **projectLocal**: Warnings logged for PATH/SHELL + +### Logging and Observability + +The sanitizer provides detailed security logging: + +``` +[EnvSanitizer] BLOCKED dangerous env var from projectShared: LD_PRELOAD (prevents code injection attack) +[EnvSanitizer] Blocked 3 dangerous env var(s) from projectShared: LD_PRELOAD, NODE_OPTIONS, PYTHONSTARTUP +[EnvSanitizer] WARNING: PATH set from projectShared settings (can affect command execution, verify this is intentional) +``` + +### Testing + +Comprehensive test coverage in: +- `__tests__/env-sanitizer.test.ts` (36 tests) - Unit tests for sanitization logic +- `__tests__/merger.test.ts` (26 tests, 6 security-focused) - Integration tests + +### Comparison to Claude Code CLI + +Claude Code CLI itself does **NOT** implement env var blocklists. It relies solely on: +- File permission rules (permissions.deny) +- User awareness of `.env` auto-loading behavior + +**Our Approach:** Defense-in-depth - we add protection even though the upstream tool doesn't, because: +1. Our terminals run arbitrary user commands (higher risk surface) +2. Supply chain attacks are a critical threat vector +3. Users expect security by default + +### References + +- [Backslash Security: Claude Code Best Practices](https://www.backslash.security/blog/claude-code-security-best-practices) +- [Knostic: Claude Loads Secrets Without Permission](https://www.knostic.ai/blog/claude-loads-secrets-without-permission) +- [Claude Code Settings Documentation](https://code.claude.com/docs/en/settings) + +### Future Enhancements + +Potential improvements for consideration: +- User-configurable blocklist extensions +- Telemetry for blocked env var attempts +- Integration with security scanning tools +- Warning UI notifications for blocked vars diff --git a/apps/frontend/src/main/claude-code-settings/__tests__/env-sanitizer.test.ts b/apps/frontend/src/main/claude-code-settings/__tests__/env-sanitizer.test.ts new file mode 100644 index 00000000..e1ae8a79 --- /dev/null +++ b/apps/frontend/src/main/claude-code-settings/__tests__/env-sanitizer.test.ts @@ -0,0 +1,588 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + sanitizeEnvVars, + isDangerousEnvVar, + isWarningEnvVar, + getDangerousEnvVars, + getWarningEnvVars, +} from '../env-sanitizer'; + +// Mock debug logger +vi.mock('../../../shared/utils/debug-logger', () => ({ + debugLog: vi.fn(), + debugError: vi.fn(), +})); + +import { debugLog, debugError } from '../../../shared/utils/debug-logger'; + +describe('env-sanitizer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('sanitizeEnvVars', () => { + it('returns empty object for undefined input', () => { + expect(sanitizeEnvVars(undefined)).toEqual({}); + }); + + it('returns empty object for null input', () => { + expect(sanitizeEnvVars(null as unknown as Record)).toEqual({}); + }); + + it('allows safe environment variables through', () => { + const env = { + NODE_ENV: 'production', + DATABASE_URL: 'postgres://localhost/db', + API_KEY: 'secret123', + DEBUG: 'app:*', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual(env); + expect(debugError).not.toHaveBeenCalled(); + }); + + it('blocks LD_PRELOAD', () => { + const env = { + LD_PRELOAD: '/tmp/malicious.so', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalled(); + // Check that some call mentions the blocked variable + const errorCalls = vi.mocked(debugError).mock.calls.flat().join(' '); + expect(errorCalls).toContain('LD_PRELOAD'); + }); + + it('blocks DYLD_INSERT_LIBRARIES on macOS', () => { + const env = { + DYLD_INSERT_LIBRARIES: '/tmp/backdoor.dylib', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalled(); + const errorCalls = vi.mocked(debugError).mock.calls.flat().join(' '); + expect(errorCalls).toContain('DYLD_INSERT_LIBRARIES'); + }); + + it('blocks NODE_OPTIONS', () => { + const env = { + NODE_OPTIONS: '--require /tmp/steal-secrets.js', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalled(); + const errorCalls = vi.mocked(debugError).mock.calls.flat().join(' '); + expect(errorCalls).toContain('NODE_OPTIONS'); + }); + + it('blocks PYTHONSTARTUP', () => { + const env = { + PYTHONSTARTUP: '/tmp/keylogger.py', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalled(); + const errorCalls = vi.mocked(debugError).mock.calls.flat().join(' '); + expect(errorCalls).toContain('PYTHONSTARTUP'); + }); + + it('blocks BASH_ENV', () => { + const env = { + BASH_ENV: '/tmp/evil.sh', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalled(); + const errorCalls = vi.mocked(debugError).mock.calls.flat().join(' '); + expect(errorCalls).toContain('BASH_ENV'); + }); + + it('blocks multiple dangerous variables', () => { + const env = { + LD_PRELOAD: '/tmp/malicious.so', + NODE_OPTIONS: '--require /tmp/evil.js', + PYTHONSTARTUP: '/tmp/bad.py', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 3 dangerous'), + expect.stringContaining('LD_PRELOAD') + ); + }); + + it('is case-insensitive for dangerous variable names', () => { + const env = { + ld_preload: '/tmp/malicious.so', + Ld_Preload: '/tmp/malicious.so', + LD_PRELOAD: '/tmp/malicious.so', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(Object.keys(result)).not.toContain('ld_preload'); + expect(Object.keys(result)).not.toContain('Ld_Preload'); + expect(Object.keys(result)).not.toContain('LD_PRELOAD'); + }); + + it('warns about PATH from project-level settings', () => { + const env = { + PATH: '/malicious/bin:/usr/bin', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env, 'projectShared'); + + expect(result).toEqual(env); // PATH is allowed but warned + expect(debugLog).toHaveBeenCalledWith( + expect.stringContaining('WARNING'), + expect.stringContaining('can affect command execution') + ); + }); + + it('does not warn about PATH from user-level settings', () => { + const env = { + PATH: '/custom/bin:/usr/bin', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env, 'user'); + + expect(result).toEqual(env); + expect(debugLog).not.toHaveBeenCalledWith( + expect.stringContaining('WARNING'), + expect.anything(), + expect.anything() + ); + }); + + it('does not warn about PATH from managed settings', () => { + const env = { + PATH: '/managed/bin:/usr/bin', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env, 'managed'); + + expect(result).toEqual(env); + expect(debugLog).not.toHaveBeenCalledWith( + expect.stringContaining('WARNING'), + expect.anything(), + expect.anything() + ); + }); + + it('warns about SHELL from project-local settings', () => { + const env = { + SHELL: '/custom/shell', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env, 'projectLocal'); + + expect(result).toEqual(env); // SHELL is allowed but warned + expect(debugLog).toHaveBeenCalledWith( + expect.stringContaining('WARNING'), + expect.stringContaining('can affect command execution') + ); + }); + + it('allows numeric keys (converted to strings)', () => { + const env = { + validKey: 'value', + // biome-ignore lint/suspicious/noExplicitAny: testing object with numeric key + 123: 'valid' as any, + }; + + const result = sanitizeEnvVars(env); + + // Numeric keys are converted to strings by JavaScript, so they're valid + expect(result).toEqual({ '123': 'valid', validKey: 'value' }); + }); + + it('skips invalid value types', () => { + const env = { + validKey: 'value', + // biome-ignore lint/suspicious/noExplicitAny: testing invalid input + invalidValue: 123 as any, + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ validKey: 'value' }); + expect(debugError).toHaveBeenCalled(); + const errorCalls = vi.mocked(debugError).mock.calls.flat().join(' '); + expect(errorCalls).toContain('Invalid env var type'); + }); + + it('blocks all Linux dynamic linker variables', () => { + const env = { + LD_PRELOAD: '/tmp/evil.so', + LD_LIBRARY_PATH: '/tmp/evil', + LD_AUDIT: '/tmp/audit.so', + LD_BIND_NOW: '1', + LD_DEBUG: 'all', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 5 dangerous'), + expect.anything() + ); + }); + + it('blocks all macOS dynamic linker variables', () => { + const env = { + DYLD_INSERT_LIBRARIES: '/tmp/evil.dylib', + DYLD_LIBRARY_PATH: '/tmp/evil', + DYLD_FRAMEWORK_PATH: '/tmp/evil', + DYLD_FALLBACK_LIBRARY_PATH: '/tmp/evil', + DYLD_FALLBACK_FRAMEWORK_PATH: '/tmp/evil', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 5 dangerous'), + expect.anything() + ); + }); + + it('blocks Node.js injection variables', () => { + const env = { + NODE_OPTIONS: '--require evil.js', + NODE_PATH: '/tmp/evil', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 2 dangerous'), + expect.anything() + ); + }); + + it('blocks Python injection variables', () => { + const env = { + PYTHONSTARTUP: '/tmp/evil.py', + PYTHONPATH: '/tmp/evil', + PYTHONINSPECT: '1', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 3 dangerous'), + expect.anything() + ); + }); + + it('blocks Ruby injection variables', () => { + const env = { + RUBYOPT: '-revil', + RUBYLIB: '/tmp/evil', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 2 dangerous'), + expect.anything() + ); + }); + + it('blocks Perl injection variables', () => { + const env = { + PERL5OPT: '-Mevil', + PERLLIB: '/tmp/evil', + PERL5LIB: '/tmp/evil', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 3 dangerous'), + expect.anything() + ); + }); + + it('blocks shell initialization variables', () => { + const env = { + BASH_ENV: '/tmp/evil.sh', + ENV: '/tmp/evil.sh', + PROMPT_COMMAND: 'curl evil.com', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 3 dangerous'), + expect.anything() + ); + }); + + it('blocks CDPATH', () => { + const env = { + CDPATH: '/tmp/evil', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalled(); + const errorCalls = vi.mocked(debugError).mock.calls.flat().join(' '); + expect(errorCalls).toContain('CDPATH'); + }); + + it('blocks JVM injection variables', () => { + const env = { + JAVA_TOOL_OPTIONS: '-javaagent:/tmp/evil.jar', + _JAVA_OPTIONS: '-Xbootclasspath/p:/tmp/evil.jar', + MAVEN_OPTS: '-javaagent:/tmp/evil.jar', + GRADLE_OPTS: '-javaagent:/tmp/evil.jar', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 4 dangerous'), + expect.anything() + ); + }); + + it('blocks package manager hijacking variables', () => { + const env = { + NPM_CONFIG_PREFIX: '/tmp/evil', + YARN_RC_FILENAME: '/tmp/evil/.yarnrc', + COMPOSER_HOME: '/tmp/evil', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 3 dangerous'), + expect.anything() + ); + }); + + it('blocks shell startup hijacking variables', () => { + const env = { + ZDOTDIR: '/tmp/evil', + INPUTRC: '/tmp/evil/.inputrc', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 2 dangerous'), + expect.anything() + ); + }); + + it('blocks Git tracing and command injection variables', () => { + const env = { + GIT_TRACE: '1', + GIT_TRACE_PACKET: '1', + GIT_SSH_COMMAND: 'evil', + SAFE_VAR: 'value', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ SAFE_VAR: 'value' }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 3 dangerous'), + expect.anything() + ); + }); + + it('handles mixed safe and dangerous variables', () => { + const env = { + NODE_ENV: 'production', + LD_PRELOAD: '/tmp/evil.so', + DATABASE_URL: 'postgres://localhost/db', + NODE_OPTIONS: '--require evil.js', + API_KEY: 'secret', + PYTHONSTARTUP: '/tmp/evil.py', + }; + + const result = sanitizeEnvVars(env); + + expect(result).toEqual({ + NODE_ENV: 'production', + DATABASE_URL: 'postgres://localhost/db', + API_KEY: 'secret', + }); + expect(debugError).toHaveBeenCalledWith( + expect.stringContaining('Blocked 3 dangerous'), + expect.anything() + ); + }); + }); + + describe('isDangerousEnvVar', () => { + it('returns true for LD_PRELOAD', () => { + expect(isDangerousEnvVar('LD_PRELOAD')).toBe(true); + }); + + it('returns true for NODE_OPTIONS', () => { + expect(isDangerousEnvVar('NODE_OPTIONS')).toBe(true); + }); + + it('returns false for safe variables', () => { + expect(isDangerousEnvVar('NODE_ENV')).toBe(false); + expect(isDangerousEnvVar('DATABASE_URL')).toBe(false); + expect(isDangerousEnvVar('API_KEY')).toBe(false); + }); + + it('is case-insensitive', () => { + expect(isDangerousEnvVar('ld_preload')).toBe(true); + expect(isDangerousEnvVar('Ld_Preload')).toBe(true); + expect(isDangerousEnvVar('node_options')).toBe(true); + }); + }); + + describe('isWarningEnvVar', () => { + it('returns true for PATH', () => { + expect(isWarningEnvVar('PATH')).toBe(true); + }); + + it('returns true for SHELL', () => { + expect(isWarningEnvVar('SHELL')).toBe(true); + }); + + it('returns false for safe variables', () => { + expect(isWarningEnvVar('NODE_ENV')).toBe(false); + expect(isWarningEnvVar('DATABASE_URL')).toBe(false); + }); + + it('is case-insensitive', () => { + expect(isWarningEnvVar('path')).toBe(true); + expect(isWarningEnvVar('Path')).toBe(true); + expect(isWarningEnvVar('shell')).toBe(true); + }); + }); + + describe('getDangerousEnvVars', () => { + it('returns sorted array of dangerous variable names', () => { + const vars = getDangerousEnvVars(); + + expect(Array.isArray(vars)).toBe(true); + expect(vars.length).toBeGreaterThan(0); + expect(vars).toContain('LD_PRELOAD'); + expect(vars).toContain('NODE_OPTIONS'); + expect(vars).toContain('PYTHONSTARTUP'); + // Check sorted + const sorted = [...vars].sort(); + expect(vars).toEqual(sorted); + }); + }); + + describe('getWarningEnvVars', () => { + it('returns sorted array of warning variable names', () => { + const vars = getWarningEnvVars(); + + expect(Array.isArray(vars)).toBe(true); + expect(vars.length).toBeGreaterThan(0); + expect(vars).toContain('PATH'); + expect(vars).toContain('SHELL'); + // Check sorted + const sorted = [...vars].sort(); + expect(vars).toEqual(sorted); + }); + }); + + describe('encoding bypass resistance', () => { + // JavaScript's toUpperCase() handles standard ASCII correctly. + // These tests document that encoding tricks don't bypass the blocklist. + + it('blocks variable names with trailing whitespace', () => { + // Env var names with spaces are technically valid in some systems + // but toUpperCase + Set.has handles them correctly (no match = allowed) + const env = { 'LD_PRELOAD ': '/tmp/evil.so', SAFE: 'ok' }; + const result = sanitizeEnvVars(env); + // Trailing space means it won't match the blocklist — this is safe because + // the OS also won't interpret "LD_PRELOAD " as LD_PRELOAD + expect(result).toEqual({ 'LD_PRELOAD ': '/tmp/evil.so', SAFE: 'ok' }); + }); + + it('blocks variable names with null bytes stripped by JS runtime', () => { + // JavaScript strings can contain \0 but they're distinct characters. + // "LD_PRELOAD\0" !== "LD_PRELOAD" so it won't match the blocklist, + // but the OS also won't interpret it as LD_PRELOAD. + const env = { 'LD_PRELOAD\0': '/tmp/evil.so', SAFE: 'ok' }; + const result = sanitizeEnvVars(env); + expect(result).toEqual({ 'LD_PRELOAD\0': '/tmp/evil.so', SAFE: 'ok' }); + }); + + it('blocks exact matches regardless of Unicode homoglyphs', () => { + // Unicode homoglyphs (e.g., Cyrillic "А" U+0410 vs Latin "A" U+0041) + // are different characters. toUpperCase won't normalize them to ASCII. + // This means homoglyphs won't match the blocklist — which is SAFE because + // the OS also won't interpret them as the real variable. + const cyrillicA = '\u0410'; // Cyrillic Capital А (looks like Latin A) + const env = { [`LD_PRELO${cyrillicA}D`]: '/tmp/evil.so', SAFE: 'ok' }; + const result = sanitizeEnvVars(env); + // Homoglyph version passes through — this is safe (OS won't match it either) + expect(result).toHaveProperty(`LD_PRELO${cyrillicA}D`); + expect(result).toHaveProperty('SAFE'); + }); + + it('still blocks the real variable even when homoglyph variant is present', () => { + const cyrillicA = '\u0410'; + const env = { + [`LD_PRELO${cyrillicA}D`]: '/tmp/fake.so', // homoglyph — passes through + LD_PRELOAD: '/tmp/real-evil.so', // real — blocked + SAFE: 'ok', + }; + const result = sanitizeEnvVars(env); + expect(result).not.toHaveProperty('LD_PRELOAD'); + expect(result).toHaveProperty(`LD_PRELO${cyrillicA}D`); + expect(result).toHaveProperty('SAFE'); + }); + }); +}); diff --git a/apps/frontend/src/main/claude-code-settings/__tests__/index.test.ts b/apps/frontend/src/main/claude-code-settings/__tests__/index.test.ts new file mode 100644 index 00000000..651b68b7 --- /dev/null +++ b/apps/frontend/src/main/claude-code-settings/__tests__/index.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ClaudeCodeSettingsHierarchy } from '../types'; + +// Mock the reader module +vi.mock('../reader', () => ({ + readAllSettings: vi.fn(), + readUserGlobalSettings: vi.fn(), + readProjectSharedSettings: vi.fn(), + readProjectLocalSettings: vi.fn(), + readManagedSettings: vi.fn(), +})); + +// Import after mocking +import { readAllSettings } from '../reader'; +import { getClaudeCodeEnv } from '../index'; + +const mockReadAllSettings = vi.mocked(readAllSettings); + +describe('getClaudeCodeEnv', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns empty object when no settings exist', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: undefined, + projectShared: undefined, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + mockReadAllSettings.mockReturnValue(hierarchy); + + const result = getClaudeCodeEnv(); + + expect(result).toEqual({}); + expect(mockReadAllSettings).toHaveBeenCalledWith(undefined); + }); + + it('returns empty object when merged settings have no env', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { model: 'claude-sonnet-4-5-20250929' }, + projectShared: undefined, + projectLocal: undefined, + managed: undefined, + merged: { model: 'claude-sonnet-4-5-20250929' }, + }; + + mockReadAllSettings.mockReturnValue(hierarchy); + + const result = getClaudeCodeEnv('/project/path'); + + expect(result).toEqual({}); + expect(mockReadAllSettings).toHaveBeenCalledWith('/project/path'); + }); + + it('returns merged env from user level only', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { env: { USER_VAR: 'user-value' } }, + projectShared: undefined, + projectLocal: undefined, + managed: undefined, + merged: { env: { USER_VAR: 'user-value' } }, + }; + + mockReadAllSettings.mockReturnValue(hierarchy); + + const result = getClaudeCodeEnv(); + + expect(result).toEqual({ USER_VAR: 'user-value' }); + }); + + it('returns merged env from multiple levels', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { env: { A: 'user', B: 'user' } }, + projectShared: { env: { B: 'shared', C: 'shared' } }, + projectLocal: { env: { C: 'local', D: 'local' } }, + managed: { env: { D: 'managed', E: 'managed' } }, + merged: { + env: { + A: 'user', + B: 'shared', + C: 'local', + D: 'managed', + E: 'managed', + }, + }, + }; + + mockReadAllSettings.mockReturnValue(hierarchy); + + const result = getClaudeCodeEnv('/project/path'); + + expect(result).toEqual({ + A: 'user', + B: 'shared', + C: 'local', + D: 'managed', + E: 'managed', + }); + expect(mockReadAllSettings).toHaveBeenCalledWith('/project/path'); + }); + + it('respects precedence when merging env vars', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { env: { PATH: '/user/bin', HOME: '/home/user' } }, + projectShared: { env: { PATH: '/project/bin' } }, + projectLocal: undefined, + managed: { env: { PATH: '/managed/bin', SHELL: '/bin/zsh' } }, + merged: { + env: { + PATH: '/managed/bin', + HOME: '/home/user', + SHELL: '/bin/zsh', + }, + }, + }; + + mockReadAllSettings.mockReturnValue(hierarchy); + + const result = getClaudeCodeEnv('/project/path'); + + // PATH should come from managed (highest precedence) + expect(result.PATH).toBe('/managed/bin'); + // HOME should come from user (only level with it) + expect(result.HOME).toBe('/home/user'); + // SHELL should come from managed (only level with it) + expect(result.SHELL).toBe('/bin/zsh'); + }); + + it('can be called without project path', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { env: { VAR: 'value' } }, + projectShared: undefined, + projectLocal: undefined, + managed: undefined, + merged: { env: { VAR: 'value' } }, + }; + + mockReadAllSettings.mockReturnValue(hierarchy); + + const result = getClaudeCodeEnv(); + + expect(result).toEqual({ VAR: 'value' }); + expect(mockReadAllSettings).toHaveBeenCalledWith(undefined); + }); + + it('handles complex environment variable merging', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + env: { + NODE_ENV: 'development', + DEBUG: 'app:*', + PORT: '3000', + }, + }, + projectShared: { + env: { + DEBUG: 'app:server', + DATABASE_URL: 'postgres://localhost/db', + }, + }, + projectLocal: { + env: { + PORT: '8080', + SECRET_KEY: 'local-secret', + }, + }, + managed: { + env: { + NODE_ENV: 'production', + LOG_LEVEL: 'info', + }, + }, + merged: { + env: { + NODE_ENV: 'production', // managed wins + DEBUG: 'app:server', // projectShared wins + PORT: '8080', // projectLocal wins + DATABASE_URL: 'postgres://localhost/db', // only in projectShared + SECRET_KEY: 'local-secret', // only in projectLocal + LOG_LEVEL: 'info', // only in managed + }, + }, + }; + + mockReadAllSettings.mockReturnValue(hierarchy); + + const result = getClaudeCodeEnv('/project/path'); + + expect(result).toEqual({ + NODE_ENV: 'production', + DEBUG: 'app:server', + PORT: '8080', + DATABASE_URL: 'postgres://localhost/db', + SECRET_KEY: 'local-secret', + LOG_LEVEL: 'info', + }); + }); + + it('returns empty object for all undefined levels with no env', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: undefined, + projectShared: undefined, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + mockReadAllSettings.mockReturnValue(hierarchy); + + const result = getClaudeCodeEnv('/project/path'); + + expect(result).toEqual({}); + }); + + it('ignores non-env settings and only returns env vars', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + model: 'claude-opus-4-6', + alwaysThinkingEnabled: true, + env: { USER_VAR: 'value' }, + permissions: { + allow: ['git'], + }, + }, + projectShared: undefined, + projectLocal: undefined, + managed: undefined, + merged: { + model: 'claude-opus-4-6', + alwaysThinkingEnabled: true, + env: { USER_VAR: 'value' }, + permissions: { + allow: ['git'], + }, + }, + }; + + mockReadAllSettings.mockReturnValue(hierarchy); + + const result = getClaudeCodeEnv(); + + // Should only return env vars, not model or permissions + expect(result).toEqual({ USER_VAR: 'value' }); + }); + + it('handles empty env object', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { env: {} }, + projectShared: undefined, + projectLocal: undefined, + managed: undefined, + merged: { env: {} }, + }; + + mockReadAllSettings.mockReturnValue(hierarchy); + + const result = getClaudeCodeEnv(); + + expect(result).toEqual({}); + }); +}); diff --git a/apps/frontend/src/main/claude-code-settings/__tests__/merger.test.ts b/apps/frontend/src/main/claude-code-settings/__tests__/merger.test.ts new file mode 100644 index 00000000..eb698805 --- /dev/null +++ b/apps/frontend/src/main/claude-code-settings/__tests__/merger.test.ts @@ -0,0 +1,594 @@ +import { describe, it, expect } from 'vitest'; +import { mergeClaudeCodeSettings } from '../merger'; +import type { ClaudeCodeSettingsHierarchy } from '../types'; + +describe('mergeClaudeCodeSettings', () => { + it('returns empty object when hierarchy has all levels undefined', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: undefined, + projectShared: undefined, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result).toEqual({}); + }); + + it('returns user settings when only user level is defined', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + model: 'claude-haiku-3-5-20250107', + alwaysThinkingEnabled: true, + env: { USER_VAR: 'user-value' }, + }, + projectShared: undefined, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result).toEqual({ + model: 'claude-haiku-3-5-20250107', + alwaysThinkingEnabled: true, + env: { USER_VAR: 'user-value' }, + }); + }); + + it('returns managed settings when only managed level is defined', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: undefined, + projectShared: undefined, + projectLocal: undefined, + managed: { + model: 'claude-sonnet-4-5-20250929', + permissions: { + deny: ['rm', 'rmdir'], + }, + }, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result).toEqual({ + model: 'claude-sonnet-4-5-20250929', + permissions: { + deny: ['rm', 'rmdir'], + }, + }); + }); + + it('overrides scalar model: user haiku, project sonnet → sonnet', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { model: 'claude-haiku-3-5-20250107' }, + projectShared: { model: 'claude-sonnet-4-5-20250929' }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.model).toBe('claude-sonnet-4-5-20250929'); + }); + + it('overrides alwaysThinkingEnabled: user true, project false → false', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { alwaysThinkingEnabled: true }, + projectShared: { alwaysThinkingEnabled: false }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.alwaysThinkingEnabled).toBe(false); + }); + + it('deep merges env: user {A:1, B:2}, project {B:3, C:4} → {A:1, B:3, C:4}', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { env: { A: '1', B: '2' } }, + projectShared: { env: { B: '3', C: '4' } }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.env).toEqual({ A: '1', B: '3', C: '4' }); + }); + + it('preserves env when only at one level', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: undefined, + projectShared: { env: { PROJECT_VAR: 'value' } }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.env).toEqual({ PROJECT_VAR: 'value' }); + }); + + it('concatenates permission arrays: user allow=[a], project allow=[b] → [a,b]', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + permissions: { + allow: ['git'], + }, + }, + projectShared: { + permissions: { + allow: ['npm'], + }, + }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.permissions?.allow).toEqual(['git', 'npm']); + }); + + it('deduplicates permission arrays: user allow=[a,b], project allow=[b,c] → [a,b,c]', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + permissions: { + allow: ['git', 'npm'], + }, + }, + projectShared: { + permissions: { + allow: ['npm', 'docker'], + }, + }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.permissions?.allow).toEqual(['git', 'npm', 'docker']); + }); + + it('overrides defaultMode: user "ask", local "plan" → "plan"', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + permissions: { + defaultMode: 'ask', + }, + }, + projectShared: undefined, + projectLocal: { + permissions: { + defaultMode: 'plan', + }, + }, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.permissions?.defaultMode).toBe('plan'); + }); + + it('respects full precedence chain: user < projectShared < projectLocal < managed', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + model: 'user-model', + env: { A: 'user' }, + permissions: { allow: ['user-tool'] }, + }, + projectShared: { + model: 'shared-model', + env: { B: 'shared' }, + permissions: { allow: ['shared-tool'] }, + }, + projectLocal: { + model: 'local-model', + env: { C: 'local' }, + permissions: { allow: ['local-tool'] }, + }, + managed: { + model: 'managed-model', + env: { D: 'managed' }, + permissions: { deny: ['dangerous-tool'] }, + }, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.model).toBe('managed-model'); + expect(result.env).toEqual({ A: 'user', B: 'shared', C: 'local', D: 'managed' }); + expect(result.permissions?.allow).toEqual(['user-tool', 'shared-tool', 'local-tool']); + expect(result.permissions?.deny).toEqual(['dangerous-tool']); + }); + + it('handles mixed levels with some undefined', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { model: 'user-model', env: { USER: 'val' } }, + projectShared: undefined, + projectLocal: { alwaysThinkingEnabled: true }, + managed: { env: { MANAGED: 'val' } }, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.model).toBe('user-model'); + expect(result.alwaysThinkingEnabled).toBe(true); + expect(result.env).toEqual({ USER: 'val', MANAGED: 'val' }); + }); + + it('preserves permissions from lower level when higher level has no permissions', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + permissions: { + allow: ['git', 'npm'], + deny: ['rm'], + ask: ['docker'], + defaultMode: 'ask', + additionalDirectories: ['/tmp'], + }, + }, + projectShared: { model: 'some-model' }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.permissions).toEqual({ + allow: ['git', 'npm'], + deny: ['rm'], + ask: ['docker'], + defaultMode: 'ask', + additionalDirectories: ['/tmp'], + }); + }); + + it('concatenates additionalDirectories and deduplicates', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + permissions: { + additionalDirectories: ['/home/user', '/tmp'], + }, + }, + projectShared: { + permissions: { + additionalDirectories: ['/tmp', '/var/log'], + }, + }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.permissions?.additionalDirectories).toEqual(['/home/user', '/tmp', '/var/log']); + }); + + it('handles empty permission arrays', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + permissions: { + allow: [], + }, + }, + projectShared: { + permissions: { + deny: [], + }, + }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + // Empty arrays from lower levels are preserved by mergeArrays + // When lower=[] and higher=undefined, mergeArrays returns [...lower] = [] + expect(result.permissions?.allow).toEqual([]); + expect(result.permissions?.deny).toEqual([]); + }); + + it('merges all permission fields correctly', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + permissions: { + allow: ['git'], + deny: ['rm'], + }, + }, + projectShared: { + permissions: { + allow: ['npm'], + ask: ['docker'], + defaultMode: 'acceptEdits', + }, + }, + projectLocal: { + permissions: { + additionalDirectories: ['/project/data'], + }, + }, + managed: { + permissions: { + deny: ['format'], + }, + }, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.permissions).toEqual({ + allow: ['git', 'npm'], + deny: ['rm', 'format'], + ask: ['docker'], + defaultMode: 'acceptEdits', + additionalDirectories: ['/project/data'], + }); + }); + + it('clears env field when result is empty object', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { model: 'some-model' }, + projectShared: undefined, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result).toEqual({ model: 'some-model' }); + expect(result.env).toBeUndefined(); + }); + + it('handles complex multi-level env merge with overrides', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + env: { + VAR1: 'user1', + VAR2: 'user2', + VAR3: 'user3', + }, + }, + projectShared: { + env: { + VAR2: 'shared2', + VAR4: 'shared4', + }, + }, + projectLocal: { + env: { + VAR3: 'local3', + VAR5: 'local5', + }, + }, + managed: { + env: { + VAR1: 'managed1', + VAR6: 'managed6', + }, + }, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.env).toEqual({ + VAR1: 'managed1', // managed wins + VAR2: 'shared2', // shared wins (local didn't override) + VAR3: 'local3', // local wins + VAR4: 'shared4', // only in shared + VAR5: 'local5', // only in local + VAR6: 'managed6', // only in managed + }); + }); + + it('preserves alwaysThinkingEnabled=false from higher precedence level', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { alwaysThinkingEnabled: true }, + projectShared: { alwaysThinkingEnabled: false }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.alwaysThinkingEnabled).toBe(false); + }); + + it('does not carry over undefined fields', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { model: 'user-model' }, + projectShared: { alwaysThinkingEnabled: true }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result).toEqual({ + model: 'user-model', + alwaysThinkingEnabled: true, + }); + expect(result.env).toBeUndefined(); + expect(result.permissions).toBeUndefined(); + }); + + // Security: Env var sanitization tests + describe('environment variable sanitization', () => { + it('blocks dangerous env vars from projectShared level', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: undefined, + projectShared: { + env: { + LD_PRELOAD: '/tmp/malicious.so', + NODE_OPTIONS: '--require evil.js', + SAFE_VAR: 'safe-value', + }, + }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + // Dangerous vars should be filtered out, safe var should remain + expect(result.env).toEqual({ SAFE_VAR: 'safe-value' }); + expect(result.env).not.toHaveProperty('LD_PRELOAD'); + expect(result.env).not.toHaveProperty('NODE_OPTIONS'); + }); + + it('blocks dangerous env vars from projectLocal level', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: undefined, + projectShared: undefined, + projectLocal: { + env: { + DYLD_INSERT_LIBRARIES: '/tmp/backdoor.dylib', + PYTHONSTARTUP: '/tmp/evil.py', + SAFE_VAR: 'safe-value', + }, + }, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.env).toEqual({ SAFE_VAR: 'safe-value' }); + expect(result.env).not.toHaveProperty('DYLD_INSERT_LIBRARIES'); + expect(result.env).not.toHaveProperty('PYTHONSTARTUP'); + }); + + it('allows user-level env vars (trusted)', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + env: { + NODE_ENV: 'development', + CUSTOM_PATH: '/custom/bin', + }, + }, + projectShared: undefined, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + expect(result.env).toEqual({ + NODE_ENV: 'development', + CUSTOM_PATH: '/custom/bin', + }); + }); + + it('blocks dangerous vars even when mixed with safe vars across levels', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + env: { + USER_VAR: 'user-value', + }, + }, + projectShared: { + env: { + LD_PRELOAD: '/tmp/evil.so', + SHARED_VAR: 'shared-value', + }, + }, + projectLocal: { + env: { + NODE_OPTIONS: '--require evil.js', + LOCAL_VAR: 'local-value', + }, + }, + managed: { + env: { + MANAGED_VAR: 'managed-value', + }, + }, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + // Safe vars from all levels should be present + expect(result.env).toEqual({ + USER_VAR: 'user-value', + SHARED_VAR: 'shared-value', + LOCAL_VAR: 'local-value', + MANAGED_VAR: 'managed-value', + }); + // Dangerous vars should be filtered + expect(result.env).not.toHaveProperty('LD_PRELOAD'); + expect(result.env).not.toHaveProperty('NODE_OPTIONS'); + }); + + it('removes env object entirely if all vars are dangerous', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: undefined, + projectShared: { + env: { + LD_PRELOAD: '/tmp/evil.so', + NODE_OPTIONS: '--require evil.js', + PYTHONSTARTUP: '/tmp/evil.py', + }, + }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + // All vars are dangerous, so env should be removed entirely + expect(result.env).toBeUndefined(); + }); + + it('allows PATH and SHELL (warning vars) from all levels', () => { + const hierarchy: ClaudeCodeSettingsHierarchy = { + user: { + env: { PATH: '/user/bin:/usr/bin' }, + }, + projectShared: { + env: { SHELL: '/bin/zsh' }, + }, + projectLocal: undefined, + managed: undefined, + merged: {}, + }; + + const result = mergeClaudeCodeSettings(hierarchy); + + // PATH and SHELL should be allowed (they only trigger warnings) + expect(result.env).toEqual({ + PATH: '/user/bin:/usr/bin', + SHELL: '/bin/zsh', + }); + }); + }); +}); diff --git a/apps/frontend/src/main/claude-code-settings/__tests__/reader.test.ts b/apps/frontend/src/main/claude-code-settings/__tests__/reader.test.ts new file mode 100644 index 00000000..b49020da --- /dev/null +++ b/apps/frontend/src/main/claude-code-settings/__tests__/reader.test.ts @@ -0,0 +1,773 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import type { ClaudeCodeSettings } from '../types'; + +// Mock fs module +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +// Mock os module +vi.mock('os', () => ({ + homedir: vi.fn(() => '/home/testuser'), +})); + +// Mock platform module +vi.mock('../../platform', () => ({ + isWindows: vi.fn(() => false), + isMacOS: vi.fn(() => false), +})); + +// Mock debug logger +vi.mock('../../../shared/utils/debug-logger', () => ({ + debugLog: vi.fn(), + debugError: vi.fn(), +})); + +// Import mocked functions after vi.mock calls +import { existsSync, readFileSync } from 'fs'; +import { homedir } from 'os'; +import { isWindows, isMacOS } from '../../platform'; + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const _mockHomedir = vi.mocked(homedir); +const mockIsWindows = vi.mocked(isWindows); +const mockIsMacOS = vi.mocked(isMacOS); + +// Build cross-platform expected paths using path.join so tests work on Windows too +const HOME = '/home/testuser'; +const USER_SETTINGS = path.join(HOME, '.claude', 'settings.json'); +const LINUX_MANAGED = '/etc/claude-code/managed-settings.json'; +const projectSettings = (projectPath: string) => path.join(projectPath, '.claude', 'settings.json'); +const projectLocalSettings = (projectPath: string) => path.join(projectPath, '.claude', 'settings.local.json'); + +// Import module under test after mocks +import { readAllSettings, readUserGlobalSettings, readProjectSharedSettings, readProjectLocalSettings, readManagedSettings } from '../reader'; + +describe('reader', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset platform mocks to Linux by default + mockIsWindows.mockReturnValue(false); + mockIsMacOS.mockReturnValue(false); + // Reset environment variables + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.ProgramFiles; + }); + + afterEach(() => { + vi.resetModules(); + }); + + describe('readUserGlobalSettings', () => { + it('returns settings when file exists and is valid JSON', () => { + const expectedSettings: ClaudeCodeSettings = { + model: 'claude-sonnet-4-5-20250929', + env: { USER_VAR: 'value' }, + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(expectedSettings)); + + const result = readUserGlobalSettings(); + + expect(result).toEqual(expectedSettings); + expect(mockExistsSync).toHaveBeenCalledWith(USER_SETTINGS); + expect(mockReadFileSync).toHaveBeenCalledWith(USER_SETTINGS, 'utf-8'); + }); + + it('returns undefined when file does not exist', () => { + mockExistsSync.mockReturnValue(false); + + const result = readUserGlobalSettings(); + + expect(result).toBeUndefined(); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + + it('returns undefined when file contains invalid JSON', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('{ invalid json }'); + + const result = readUserGlobalSettings(); + + expect(result).toBeUndefined(); + }); + + it('uses CLAUDE_CONFIG_DIR env var when set', () => { + process.env.CLAUDE_CONFIG_DIR = '/custom/config'; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ model: 'test' })); + + readUserGlobalSettings(); + + expect(mockExistsSync).toHaveBeenCalledWith(path.join('/custom/config', 'settings.json')); + }); + + it('falls back to ~/.claude when no profile manager', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ model: 'test' })); + + readUserGlobalSettings(); + + expect(mockExistsSync).toHaveBeenCalledWith(USER_SETTINGS); + }); + + it('sanitizes env field when it is a string instead of object', () => { + const invalidSettings = { + model: 'valid-model', + env: 'not-an-object', + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(invalidSettings)); + + const result = readUserGlobalSettings(); + + // Should keep valid fields, remove invalid env + expect(result).toEqual({ + model: 'valid-model', + }); + }); + + it('sanitizes env field when it is an array', () => { + const invalidSettings = { + model: 'valid-model', + env: ['VAR1=value1', 'VAR2=value2'], + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(invalidSettings)); + + const result = readUserGlobalSettings(); + + // Should keep valid fields, remove invalid env + expect(result).toEqual({ + model: 'valid-model', + }); + }); + + it('sanitizes env field when it is a number', () => { + const invalidSettings = { + model: 'valid-model', + env: 12345, + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(invalidSettings)); + + const result = readUserGlobalSettings(); + + // Should keep valid fields, remove invalid env + expect(result).toEqual({ + model: 'valid-model', + }); + }); + + it('sanitizes env field with non-string values, keeping only valid entries', () => { + const invalidSettings = { + model: 'valid-model', + env: { + VALID_STRING: 'value', + INVALID_NUMBER: 123, + INVALID_BOOLEAN: true, + INVALID_NULL: null, + INVALID_OBJECT: { nested: 'object' }, + ANOTHER_VALID: 'another-value', + }, + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(invalidSettings)); + + const result = readUserGlobalSettings(); + + // Should keep valid fields and only valid env entries + expect(result).toEqual({ + model: 'valid-model', + env: { + VALID_STRING: 'value', + ANOTHER_VALID: 'another-value', + }, + }); + }); + + it('removes env field if all entries are invalid', () => { + const invalidSettings = { + model: 'valid-model', + env: { + NUMBER: 123, + BOOLEAN: false, + NULL: null, + }, + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(invalidSettings)); + + const result = readUserGlobalSettings(); + + // Should keep valid fields, remove env entirely + expect(result).toEqual({ + model: 'valid-model', + }); + }); + + it('sanitizes invalid model field (non-string)', () => { + const invalidSettings = { + model: 12345, + env: { VALID: 'value' }, + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(invalidSettings)); + + const result = readUserGlobalSettings(); + + // Should keep valid fields, remove invalid model + expect(result).toEqual({ + env: { VALID: 'value' }, + }); + }); + + it('sanitizes invalid alwaysThinkingEnabled field (non-boolean)', () => { + const invalidSettings = { + model: 'valid-model', + alwaysThinkingEnabled: 'yes', + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(invalidSettings)); + + const result = readUserGlobalSettings(); + + // Should keep valid fields, remove invalid alwaysThinkingEnabled + expect(result).toEqual({ + model: 'valid-model', + }); + }); + + it('sanitizes invalid permissions field (non-object)', () => { + const invalidSettings = { + model: 'valid-model', + permissions: 'not-an-object', + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(invalidSettings)); + + const result = readUserGlobalSettings(); + + // Should keep valid fields, remove invalid permissions + expect(result).toEqual({ + model: 'valid-model', + }); + }); + + it('sanitizes permissions with invalid array entries, keeping valid ones', () => { + const invalidSettings = { + model: 'valid-model', + permissions: { + allow: ['git', 123, 'npm', false, null], + deny: [456, true], + }, + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(invalidSettings)); + + const result = readUserGlobalSettings(); + + // Should keep valid strings in arrays, remove deny if empty after sanitization + expect(result).toEqual({ + model: 'valid-model', + permissions: { + allow: ['git', 'npm'], + }, + }); + }); + + it('sanitizes permissions with invalid defaultMode', () => { + const invalidSettings = { + model: 'valid-model', + permissions: { + defaultMode: 'invalid-mode', + allow: ['git'], + }, + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(invalidSettings)); + + const result = readUserGlobalSettings(); + + // Should keep valid fields, remove invalid defaultMode + expect(result).toEqual({ + model: 'valid-model', + permissions: { + allow: ['git'], + }, + }); + }); + + it('keeps valid defaultMode values (ask, acceptEdits, plan)', () => { + const validSettings = { + model: 'valid-model', + permissions: { + defaultMode: 'ask', + }, + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(validSettings)); + + const result = readUserGlobalSettings(); + + expect(result).toEqual(validSettings); + }); + + it('returns undefined when all fields are invalid', () => { + const invalidSettings = { + model: 12345, + env: 'not-an-object', + alwaysThinkingEnabled: 'yes', + permissions: 'not-an-object', + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(invalidSettings)); + + const result = readUserGlobalSettings(); + + // Should return undefined because no valid fields remain + expect(result).toBeUndefined(); + }); + }); + + describe('readProjectSharedSettings', () => { + it('returns settings when file exists and is valid JSON', () => { + const expectedSettings: ClaudeCodeSettings = { + permissions: { + allow: ['git', 'npm'], + }, + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(expectedSettings)); + + const result = readProjectSharedSettings('/project/path'); + + expect(result).toEqual(expectedSettings); + expect(mockExistsSync).toHaveBeenCalledWith(projectSettings('/project/path')); + expect(mockReadFileSync).toHaveBeenCalledWith(projectSettings('/project/path'), 'utf-8'); + }); + + it('returns undefined when file does not exist', () => { + mockExistsSync.mockReturnValue(false); + + const result = readProjectSharedSettings('/project/path'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when file contains invalid JSON', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('not valid json'); + + const result = readProjectSharedSettings('/project/path'); + + expect(result).toBeUndefined(); + }); + }); + + describe('readProjectLocalSettings', () => { + it('returns settings when file exists and is valid JSON', () => { + const expectedSettings: ClaudeCodeSettings = { + alwaysThinkingEnabled: true, + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(expectedSettings)); + + const result = readProjectLocalSettings('/project/path'); + + expect(result).toEqual(expectedSettings); + expect(mockExistsSync).toHaveBeenCalledWith(projectLocalSettings('/project/path')); + expect(mockReadFileSync).toHaveBeenCalledWith(projectLocalSettings('/project/path'), 'utf-8'); + }); + + it('returns undefined when file does not exist', () => { + mockExistsSync.mockReturnValue(false); + + const result = readProjectLocalSettings('/project/path'); + + expect(result).toBeUndefined(); + }); + }); + + describe('readManagedSettings', () => { + it('reads from Linux path when on Linux', () => { + mockIsWindows.mockReturnValue(false); + mockIsMacOS.mockReturnValue(false); + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ model: 'managed' })); + + const result = readManagedSettings(); + + expect(mockExistsSync).toHaveBeenCalledWith(LINUX_MANAGED); + expect(result).toEqual({ model: 'managed' }); + }); + + it('reads from macOS path when on macOS', () => { + mockIsWindows.mockReturnValue(false); + mockIsMacOS.mockReturnValue(true); + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ model: 'managed' })); + + const result = readManagedSettings(); + + expect(mockExistsSync).toHaveBeenCalledWith('/Library/Application Support/ClaudeCode/managed-settings.json'); + expect(result).toEqual({ model: 'managed' }); + }); + + it('reads from Windows path when on Windows', () => { + mockIsWindows.mockReturnValue(true); + process.env.ProgramFiles = 'C:\\Program Files'; + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ model: 'managed' })); + + const result = readManagedSettings(); + + // path.join uses forward slashes on non-Windows, backslashes on Windows + // Accept either format since tests run on different platforms + const callArg = mockExistsSync.mock.calls[0][0] as string; + expect(callArg).toMatch(/C:\\Program Files[\\/]ClaudeCode[\\/]managed-settings\.json/); + expect(result).toEqual({ model: 'managed' }); + }); + + it('uses default Windows path when ProgramFiles env is not set', () => { + mockIsWindows.mockReturnValue(true); + delete process.env.ProgramFiles; + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ model: 'managed' })); + + readManagedSettings(); + + // Accept either forward or backslashes + const callArg = mockExistsSync.mock.calls[0][0] as string; + expect(callArg).toMatch(/C:\\Program Files[\\/]ClaudeCode[\\/]managed-settings\.json/); + }); + + it('returns undefined when managed settings file does not exist', () => { + mockExistsSync.mockReturnValue(false); + + const result = readManagedSettings(); + + expect(result).toBeUndefined(); + }); + }); + + describe('readAllSettings', () => { + beforeEach(() => { + // Reset all mocks before each test + mockExistsSync.mockReturnValue(false); + mockReadFileSync.mockReturnValue('{}'); + }); + + it('reads only user and managed settings when no project path', () => { + mockExistsSync.mockImplementation((p) => { + const s = String(p); + return s === USER_SETTINGS || s === LINUX_MANAGED; + }); + + mockReadFileSync.mockImplementation((p) => { + const s = String(p); + if (s === USER_SETTINGS) { + return JSON.stringify({ model: 'user-model' }); + } + if (s === LINUX_MANAGED) { + return JSON.stringify({ alwaysThinkingEnabled: false }); + } + return '{}'; + }); + + const result = readAllSettings(); + + expect(result.user).toEqual({ model: 'user-model' }); + expect(result.projectShared).toBeUndefined(); + expect(result.projectLocal).toBeUndefined(); + expect(result.managed).toEqual({ alwaysThinkingEnabled: false }); + expect(result.merged).toEqual({ + model: 'user-model', + alwaysThinkingEnabled: false, + }); + }); + + it('reads all 4 levels when project path is provided', () => { + mockExistsSync.mockReturnValue(true); + + mockReadFileSync.mockImplementation((p) => { + const s = String(p); + if (s === USER_SETTINGS) { + return JSON.stringify({ model: 'user-model' }); + } + if (s === projectSettings('/project')) { + return JSON.stringify({ env: { PROJECT: 'shared' } }); + } + if (s === projectLocalSettings('/project')) { + return JSON.stringify({ alwaysThinkingEnabled: true }); + } + if (s === LINUX_MANAGED) { + return JSON.stringify({ permissions: { deny: ['rm'] } }); + } + return '{}'; + }); + + const result = readAllSettings('/project'); + + expect(result.user).toEqual({ model: 'user-model' }); + expect(result.projectShared).toEqual({ env: { PROJECT: 'shared' } }); + expect(result.projectLocal).toEqual({ alwaysThinkingEnabled: true }); + expect(result.managed).toEqual({ permissions: { deny: ['rm'] } }); + expect(result.merged).toEqual({ + model: 'user-model', + env: { PROJECT: 'shared' }, + alwaysThinkingEnabled: true, + permissions: { deny: ['rm'] }, + }); + }); + + it('handles missing files by returning undefined for those levels', () => { + mockExistsSync.mockImplementation((p) => { + return String(p) === USER_SETTINGS; + }); + + mockReadFileSync.mockImplementation((p) => { + if (String(p) === USER_SETTINGS) { + return JSON.stringify({ model: 'user-only' }); + } + return '{}'; + }); + + const result = readAllSettings('/project'); + + expect(result.user).toEqual({ model: 'user-only' }); + expect(result.projectShared).toBeUndefined(); + expect(result.projectLocal).toBeUndefined(); + expect(result.managed).toBeUndefined(); + expect(result.merged).toEqual({ model: 'user-only' }); + }); + + it('handles invalid JSON by returning undefined for that level', () => { + mockExistsSync.mockReturnValue(true); + + mockReadFileSync.mockImplementation((p) => { + const s = String(p); + if (s === USER_SETTINGS) { + return JSON.stringify({ model: 'valid' }); + } + if (s === projectSettings('/project')) { + return '{ invalid json'; + } + return '{}'; + }); + + const result = readAllSettings('/project'); + + expect(result.user).toEqual({ model: 'valid' }); + expect(result.projectShared).toBeUndefined(); + expect(result.merged).toEqual({ model: 'valid' }); + }); + + it('returns empty merged object when all levels are undefined', () => { + mockExistsSync.mockReturnValue(false); + + const result = readAllSettings(); + + expect(result.user).toBeUndefined(); + expect(result.projectShared).toBeUndefined(); + expect(result.projectLocal).toBeUndefined(); + expect(result.managed).toBeUndefined(); + expect(result.merged).toEqual({}); + }); + + it('merges settings with correct precedence', () => { + mockExistsSync.mockReturnValue(true); + + mockReadFileSync.mockImplementation((p) => { + const s = String(p); + if (s === USER_SETTINGS) { + return JSON.stringify({ + model: 'user-model', + env: { A: 'user', B: 'user' }, + permissions: { allow: ['user-tool'] }, + }); + } + if (s === projectSettings('/project')) { + return JSON.stringify({ + model: 'shared-model', + env: { B: 'shared', C: 'shared' }, + permissions: { allow: ['shared-tool'] }, + }); + } + if (s === projectLocalSettings('/project')) { + return JSON.stringify({ + env: { C: 'local', D: 'local' }, + }); + } + if (s === LINUX_MANAGED) { + return JSON.stringify({ + model: 'managed-model', + permissions: { deny: ['dangerous'] }, + }); + } + return '{}'; + }); + + const result = readAllSettings('/project'); + + expect(result.merged.model).toBe('managed-model'); // managed wins + expect(result.merged.env).toEqual({ + A: 'user', + B: 'shared', + C: 'local', + D: 'local', + }); + expect(result.merged.permissions).toEqual({ + allow: ['user-tool', 'shared-tool'], + deny: ['dangerous'], + }); + }); + + it('sanitizes invalid env values across multiple levels and merges correctly', () => { + mockExistsSync.mockReturnValue(true); + + mockReadFileSync.mockImplementation((p) => { + const s = String(p); + if (s === USER_SETTINGS) { + return JSON.stringify({ + model: 'user-model', + env: 'not-an-object', // Invalid - should be removed + }); + } + if (s === projectSettings('/project')) { + return JSON.stringify({ + env: { VALID: 'shared', INVALID: 123 }, // Partial sanitization + }); + } + if (s === projectLocalSettings('/project')) { + return JSON.stringify({ + env: { OVERRIDE: 'local' }, + }); + } + return '{}'; + }); + + const result = readAllSettings('/project'); + + // User env should be removed, project shared should be sanitized, project local should be kept + expect(result.user).toEqual({ model: 'user-model' }); + expect(result.projectShared).toEqual({ env: { VALID: 'shared' } }); + expect(result.projectLocal).toEqual({ env: { OVERRIDE: 'local' } }); + expect(result.merged).toEqual({ + model: 'user-model', + env: { + VALID: 'shared', + OVERRIDE: 'local', + }, + }); + }); + + it('sanitizes invalid permissions across multiple levels and merges correctly', () => { + mockExistsSync.mockReturnValue(true); + + mockReadFileSync.mockImplementation((p) => { + const s = String(p); + if (s === USER_SETTINGS) { + return JSON.stringify({ + permissions: { + allow: ['git', 123, 'npm'], // Mixed valid/invalid + defaultMode: 'invalid', // Invalid - should be removed + }, + }); + } + if (s === projectSettings('/project')) { + return JSON.stringify({ + permissions: 'not-an-object', // Invalid - should be removed entirely + }); + } + if (s === LINUX_MANAGED) { + return JSON.stringify({ + permissions: { + deny: ['rm', false, 'sudo'], // Mixed valid/invalid + defaultMode: 'ask', // Valid + }, + }); + } + return '{}'; + }); + + const result = readAllSettings('/project'); + + // User permissions should be sanitized, project shared should be removed, managed should be sanitized + expect(result.user).toEqual({ + permissions: { + allow: ['git', 'npm'], + }, + }); + expect(result.projectShared).toBeUndefined(); + expect(result.managed).toEqual({ + permissions: { + deny: ['rm', 'sudo'], + defaultMode: 'ask', + }, + }); + expect(result.merged).toEqual({ + permissions: { + allow: ['git', 'npm'], + deny: ['rm', 'sudo'], + defaultMode: 'ask', + }, + }); + }); + + it('handles completely invalid settings at one level while keeping valid levels', () => { + mockExistsSync.mockReturnValue(true); + + mockReadFileSync.mockImplementation((p) => { + const s = String(p); + if (s === USER_SETTINGS) { + return JSON.stringify({ + model: 12345, // Invalid + env: 'not-an-object', // Invalid + alwaysThinkingEnabled: 'yes', // Invalid + permissions: 'not-an-object', // Invalid + }); + } + if (s === projectSettings('/project')) { + return JSON.stringify({ + model: 'valid-project-model', + env: { VALID: 'value' }, + }); + } + return '{}'; + }); + + const result = readAllSettings('/project'); + + // User settings should be completely removed, project settings should be kept + expect(result.user).toBeUndefined(); + expect(result.projectShared).toEqual({ + model: 'valid-project-model', + env: { VALID: 'value' }, + }); + expect(result.merged).toEqual({ + model: 'valid-project-model', + env: { VALID: 'value' }, + }); + }); + }); +}); diff --git a/apps/frontend/src/main/claude-code-settings/env-sanitizer.ts b/apps/frontend/src/main/claude-code-settings/env-sanitizer.ts new file mode 100644 index 00000000..02db4f82 --- /dev/null +++ b/apps/frontend/src/main/claude-code-settings/env-sanitizer.ts @@ -0,0 +1,217 @@ +/** + * Environment Variable Sanitizer + * + * Filters dangerous environment variables from Claude Code settings to prevent + * supply chain attacks via malicious project settings.json files. + * + * Attack Vector: + * A malicious repository can include .claude/settings.json (committed file) with + * dangerous env vars like LD_PRELOAD, NODE_OPTIONS, etc. When a user opens the + * project and creates a terminal, these vars get injected into the PTY process, + * enabling arbitrary code execution. + * + * Defense Strategy: + * - Block environment variables that enable code injection + * - Log warnings for blocked variables + * - Special handling for PATH (warn but don't block) + */ + +import { debugLog, debugError } from '../../shared/utils/debug-logger'; + +const LOG_PREFIX = '[EnvSanitizer]'; + +/** + * Environment variables that enable arbitrary code execution and MUST be blocked. + * These variables allow attackers to inject malicious libraries, scripts, or commands + * into the runtime environment. + * + * Categories: + * - Dynamic linker injection (LD_*, DYLD_*) + * - Runtime module loaders (NODE_OPTIONS, PYTHON*, RUBY*, PERL*) + * - Shell initialization (BASH_ENV, ENV, ZDOTDIR, INPUTRC) + * - JVM injection (JAVA_TOOL_OPTIONS, MAVEN_OPTS, GRADLE_OPTS) + * - Package manager hijacking (NPM_CONFIG_PREFIX, YARN_RC_FILENAME, COMPOSER_HOME) + * - Path manipulation that can hijack commands (CDPATH) + */ +const DANGEROUS_ENV_VARS = new Set([ + // Linux/Unix dynamic linker - allows loading arbitrary shared libraries + 'LD_PRELOAD', + 'LD_LIBRARY_PATH', + 'LD_AUDIT', + 'LD_BIND_NOW', + 'LD_DEBUG', + + // macOS dynamic linker - allows loading arbitrary dylibs/frameworks + 'DYLD_INSERT_LIBRARIES', + 'DYLD_LIBRARY_PATH', + 'DYLD_FRAMEWORK_PATH', + 'DYLD_FALLBACK_LIBRARY_PATH', + 'DYLD_FALLBACK_FRAMEWORK_PATH', + 'DYLD_VERSIONED_LIBRARY_PATH', + 'DYLD_VERSIONED_FRAMEWORK_PATH', + + // Node.js - allows arbitrary module loading and flag injection + 'NODE_OPTIONS', + 'NODE_PATH', + + // Python - allows running arbitrary Python code at startup + 'PYTHONSTARTUP', + 'PYTHONPATH', + 'PYTHONINSPECT', + + // Ruby - allows injecting arbitrary Ruby code + 'RUBYOPT', + 'RUBYLIB', + + // Perl - allows injecting arbitrary Perl code + 'PERL5OPT', + 'PERLLIB', + 'PERL5LIB', + + // Shell initialization - allows running arbitrary commands + 'BASH_ENV', + 'ENV', + 'ZDOTDIR', // zsh startup directory hijacking + 'PROMPT_COMMAND', + 'INPUTRC', // readline command injection + + // Path manipulation - can cause 'cd' to execute malicious code + 'CDPATH', + + // JVM injection - allows arbitrary agent/code loading + 'JAVA_TOOL_OPTIONS', + '_JAVA_OPTIONS', + 'MAVEN_OPTS', + 'GRADLE_OPTS', + + // Python additional - user site-packages hijacking + 'PYTHONUSERBASE', + + // Package manager hijacking + 'NPM_CONFIG_PREFIX', + 'YARN_RC_FILENAME', + 'COMPOSER_HOME', + + // Git injection + 'GIT_TRACE', + 'GIT_TRACE_PACKET', + 'GIT_TRACE_PERFORMANCE', + 'GIT_SSH_COMMAND', + 'GIT_ALLOW_PROTOCOL', +]); + +/** + * Environment variables that should trigger warnings when set from project-level + * settings (shared or local), but are not blocked entirely since they may be + * legitimately needed. + */ +const WARNING_ENV_VARS = new Set([ + 'PATH', // Can hijack command execution if malicious paths are prepended + 'SHELL', // Changing shell can affect command execution + 'TERM', // Can affect terminal behavior in unexpected ways +]); + +/** + * Sanitize environment variables by removing dangerous entries that could enable + * supply chain attacks. + * + * @param env - Raw environment variables from settings.json + * @param sourceLevel - The settings level these vars came from (for logging) + * @returns Sanitized environment variables with dangerous entries removed + */ +export function sanitizeEnvVars( + env: Record | undefined, + sourceLevel: 'user' | 'projectShared' | 'projectLocal' | 'managed' = 'user' +): Record { + if (!env || typeof env !== 'object') { + return {}; + } + + const sanitized: Record = {}; + const blocked: string[] = []; + const warned: string[] = []; + + for (const [key, value] of Object.entries(env)) { + // Validate key and value types + if (typeof key !== 'string' || typeof value !== 'string') { + debugError(`${LOG_PREFIX} Invalid env var type: ${typeof key}=${typeof value}`); + continue; + } + + const upperKey = key.toUpperCase(); + + // Block dangerous variables + if (DANGEROUS_ENV_VARS.has(upperKey)) { + blocked.push(key); + debugError( + `${LOG_PREFIX} BLOCKED dangerous env var from ${sourceLevel}: ${key}`, + '(prevents code injection attack)' + ); + continue; + } + + // Warn about potentially dangerous variables when set from project-level settings + // (User-level and managed settings are considered trusted) + if ( + (sourceLevel === 'projectShared' || sourceLevel === 'projectLocal') && + WARNING_ENV_VARS.has(upperKey) + ) { + warned.push(key); + debugLog( + `${LOG_PREFIX} WARNING: ${key} set from ${sourceLevel} settings`, + '(can affect command execution, verify this is intentional)' + ); + } + + sanitized[key] = value; + } + + // Log summary if any variables were filtered + if (blocked.length > 0) { + debugError( + `${LOG_PREFIX} Blocked ${blocked.length} dangerous env var(s) from ${sourceLevel}:`, + blocked.join(', ') + ); + } + + if (warned.length > 0) { + debugLog( + `${LOG_PREFIX} ${warned.length} potentially dangerous env var(s) from ${sourceLevel}:`, + warned.join(', ') + ); + } + + return sanitized; +} + +/** + * Check if an environment variable is considered dangerous. + * Useful for validation and testing. + */ +export function isDangerousEnvVar(key: string): boolean { + return DANGEROUS_ENV_VARS.has(key.toUpperCase()); +} + +/** + * Check if an environment variable should trigger a warning when set from + * project-level settings. + */ +export function isWarningEnvVar(key: string): boolean { + return WARNING_ENV_VARS.has(key.toUpperCase()); +} + +/** + * Get the complete list of blocked environment variable names. + * Useful for documentation and testing. + */ +export function getDangerousEnvVars(): string[] { + return Array.from(DANGEROUS_ENV_VARS).sort(); +} + +/** + * Get the complete list of warning environment variable names. + * Useful for documentation and testing. + */ +export function getWarningEnvVars(): string[] { + return Array.from(WARNING_ENV_VARS).sort(); +} diff --git a/apps/frontend/src/main/claude-code-settings/index.ts b/apps/frontend/src/main/claude-code-settings/index.ts new file mode 100644 index 00000000..8bacfb2f --- /dev/null +++ b/apps/frontend/src/main/claude-code-settings/index.ts @@ -0,0 +1,55 @@ +/** + * Claude Code CLI Settings Module + * + * Reads and merges Claude Code CLI settings files from the user's system. + * These settings are separate from Auto Claude's own settings (settings-utils.ts). + * + * Usage: + * import { getClaudeCodeEnv, readAllSettings } from './claude-code-settings'; + * + * // Quick: just get the merged env vars for spawning processes + * const env = getClaudeCodeEnv('/path/to/project'); + * + * // Full: get the entire settings hierarchy + * const hierarchy = readAllSettings('/path/to/project'); + */ + +export type { + ClaudeCodeSettings, + ClaudeCodePermissions, + ClaudeCodeSettingsHierarchy, +} from './types'; + +export { + readUserGlobalSettings, + readProjectSharedSettings, + readProjectLocalSettings, + readManagedSettings, + readAllSettings, +} from './reader'; + +export { mergeClaudeCodeSettings } from './merger'; + +export { + sanitizeEnvVars, + isDangerousEnvVar, + isWarningEnvVar, + getDangerousEnvVars, + getWarningEnvVars, +} from './env-sanitizer'; + +import { readAllSettings as _readAllSettings } from './reader'; + +/** + * Convenience function: read all settings levels, merge, and return just the env object. + * + * This is the primary API for callers that only need environment variables + * (e.g., terminal PTY spawning, agent process spawning). + * + * @param projectPath - Optional project path for project-level settings. + * @returns Merged env record, or empty object if no env vars are configured. + */ +export function getClaudeCodeEnv(projectPath?: string): Record { + const hierarchy = _readAllSettings(projectPath); + return hierarchy.merged.env ?? {}; +} diff --git a/apps/frontend/src/main/claude-code-settings/merger.ts b/apps/frontend/src/main/claude-code-settings/merger.ts new file mode 100644 index 00000000..6a51382c --- /dev/null +++ b/apps/frontend/src/main/claude-code-settings/merger.ts @@ -0,0 +1,170 @@ +/** + * Claude Code Settings Merger + * + * Merges settings from multiple precedence levels into a single result. + * + * Precedence (lowest to highest): + * 1. User Global + * 2. Shared Project + * 3. Local Project + * 4. Managed (system-wide) + * + * Merge rules: + * - Scalar values (model, alwaysThinkingEnabled, defaultMode): higher precedence wins + * - env object: deep merge with sanitization, higher precedence wins conflicts + * - Permission arrays (allow, deny, ask): concatenate unique values + * - additionalDirectories: concatenate unique values + * + * Security: + * - Environment variables are sanitized to prevent supply chain attacks + * - Dangerous variables (LD_PRELOAD, NODE_OPTIONS, etc.) are blocked + */ + +import type { ClaudeCodeSettings, ClaudeCodeSettingsHierarchy } from './types'; +import { sanitizeEnvVars } from './env-sanitizer'; + +/** + * Merge two env objects with sanitization. Values from `higher` override `lower` + * on key conflicts. Dangerous environment variables are filtered out to prevent + * supply chain attacks. + * + * @param lower - Lower precedence env vars + * @param higher - Higher precedence env vars + * @param lowerLevel - Source level of lower env vars (for sanitization logging) + * @param higherLevel - Source level of higher env vars (for sanitization logging) + */ +function mergeEnv( + lower: Record | undefined, + higher: Record | undefined, + lowerLevel: 'user' | 'projectShared' | 'projectLocal' | 'managed' = 'user', + higherLevel: 'user' | 'projectShared' | 'projectLocal' | 'managed' = 'user' +): Record | undefined { + if (!lower && !higher) return undefined; + if (!lower) return sanitizeEnvVars(higher, higherLevel); + if (!higher) return sanitizeEnvVars(lower, lowerLevel); + + // Sanitize both levels before merging + const sanitizedLower = sanitizeEnvVars(lower, lowerLevel); + const sanitizedHigher = sanitizeEnvVars(higher, higherLevel); + + return { ...sanitizedLower, ...sanitizedHigher }; +} + +/** + * Merge two string arrays, keeping only unique values. + */ +function mergeArrays( + lower: string[] | undefined, + higher: string[] | undefined, +): string[] | undefined { + if (!lower && !higher) return undefined; + if (!lower) return higher ? [...higher] : undefined; + if (!higher) return [...lower]; + + const combined = [...lower, ...higher]; + return [...new Set(combined)]; +} + +/** + * Merge two settings levels. Higher precedence values override lower for scalars; + * arrays are concatenated; env is deep-merged with sanitization. + * + * @param lower - Lower precedence settings + * @param higher - Higher precedence settings + * @param lowerLevel - Source level of lower settings (for env sanitization) + * @param higherLevel - Source level of higher settings (for env sanitization) + */ +function mergeTwoLevels( + lower: ClaudeCodeSettings | undefined, + higher: ClaudeCodeSettings | undefined, + lowerLevel: 'user' | 'projectShared' | 'projectLocal' | 'managed' = 'user', + higherLevel: 'user' | 'projectShared' | 'projectLocal' | 'managed' = 'user' +): ClaudeCodeSettings { + if (!lower && !higher) return {}; + if (!lower) { + const result = { ...higher } as ClaudeCodeSettings; + // Sanitize env vars from the higher level + if (result.env) { + result.env = sanitizeEnvVars(result.env, higherLevel); + if (Object.keys(result.env).length === 0) { + delete result.env; + } + } + return result; + } + if (!higher) { + const result = { ...lower }; + // Sanitize env vars from the lower level + if (result.env) { + result.env = sanitizeEnvVars(result.env, lowerLevel); + if (Object.keys(result.env).length === 0) { + delete result.env; + } + } + return result; + } + + const result: ClaudeCodeSettings = { ...lower }; + + // Scalar overrides + if (higher.model !== undefined) { + result.model = higher.model; + } + if (higher.alwaysThinkingEnabled !== undefined) { + result.alwaysThinkingEnabled = higher.alwaysThinkingEnabled; + } + + // Deep merge env with sanitization + result.env = mergeEnv(lower.env, higher.env, lowerLevel, higherLevel); + if (!result.env || Object.keys(result.env).length === 0) { + delete result.env; + } + + // Merge permissions + if (lower.permissions || higher.permissions) { + const lp = lower.permissions ?? {}; + const hp = higher.permissions ?? {}; + + result.permissions = { + ...lp, + // Scalar override for defaultMode + ...(hp.defaultMode !== undefined ? { defaultMode: hp.defaultMode } : {}), + // Array merges + allow: mergeArrays(lp.allow, hp.allow), + deny: mergeArrays(lp.deny, hp.deny), + ask: mergeArrays(lp.ask, hp.ask), + additionalDirectories: mergeArrays(lp.additionalDirectories, hp.additionalDirectories), + }; + + // Clean up undefined array fields + if (!result.permissions.allow) delete result.permissions.allow; + if (!result.permissions.deny) delete result.permissions.deny; + if (!result.permissions.ask) delete result.permissions.ask; + if (!result.permissions.additionalDirectories) delete result.permissions.additionalDirectories; + if (!result.permissions.defaultMode) delete result.permissions.defaultMode; + } + + return result; +} + +/** + * Merge the full settings hierarchy into a single ClaudeCodeSettings object. + * + * Applies precedence: user (lowest) -> projectShared -> projectLocal -> managed (highest) + * + * Security: Environment variables are sanitized at each level to prevent supply + * chain attacks via malicious project settings.json files. + */ +export function mergeClaudeCodeSettings( + hierarchy: ClaudeCodeSettingsHierarchy, +): ClaudeCodeSettings { + let merged: ClaudeCodeSettings = {}; + + // Merge with level tracking for proper env sanitization + merged = mergeTwoLevels(merged, hierarchy.user, 'user', 'user'); + merged = mergeTwoLevels(merged, hierarchy.projectShared, 'user', 'projectShared'); + merged = mergeTwoLevels(merged, hierarchy.projectLocal, 'projectShared', 'projectLocal'); + merged = mergeTwoLevels(merged, hierarchy.managed, 'projectLocal', 'managed'); + + return merged; +} diff --git a/apps/frontend/src/main/claude-code-settings/reader.ts b/apps/frontend/src/main/claude-code-settings/reader.ts new file mode 100644 index 00000000..c8141a8d --- /dev/null +++ b/apps/frontend/src/main/claude-code-settings/reader.ts @@ -0,0 +1,328 @@ +/** + * Claude Code CLI Settings Reader + * + * Reads Claude Code settings files from the 3 file-based levels plus + * optional managed (system-wide) settings. Follows the same synchronous + * read pattern as settings-utils.ts for consistency. + * + * Settings hierarchy (lowest to highest precedence): + * 1. User Global: ~/.claude/settings.json (or CLAUDE_CONFIG_DIR/settings.json) + * 2. Shared Project: {projectPath}/.claude/settings.json + * 3. Local Project: {projectPath}/.claude/settings.local.json + * 4. Managed: Platform-specific system path (highest precedence) + */ + +import { existsSync, readFileSync } from 'fs'; +import { homedir } from 'os'; +import path from 'path'; +import { isWindows, isMacOS } from '../platform'; +import type { ClaudeCodeSettings, ClaudeCodeSettingsHierarchy } from './types'; +import { mergeClaudeCodeSettings } from './merger'; +import { debugLog, debugError } from '../../shared/utils/debug-logger'; + +const LOG_PREFIX = '[ClaudeCodeSettings]'; + +/** + * Check if a value is a plain object (not null, not array, not other special object types) + */ +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Validate and sanitize the env field to ensure it's a Record. + * Returns undefined if the field is invalid or empty after sanitization. + */ +function sanitizeEnv(env: unknown): Record | undefined { + if (!isPlainObject(env)) { + return undefined; + } + + const sanitized: Record = {}; + let hasValidEntries = false; + + for (const [key, value] of Object.entries(env)) { + if (typeof key === 'string' && typeof value === 'string') { + sanitized[key] = value; + hasValidEntries = true; + } else { + debugLog(`${LOG_PREFIX} Skipping invalid env entry:`, { key, value: typeof value }); + } + } + + return hasValidEntries ? sanitized : undefined; +} + +/** + * Validate and sanitize the permissions field structure. + * Returns undefined if the field is invalid or empty after sanitization. + */ +function sanitizePermissions(permissions: unknown): ClaudeCodeSettings['permissions'] | undefined { + if (!isPlainObject(permissions)) { + return undefined; + } + + const result: ClaudeCodeSettings['permissions'] = {}; + let hasValidFields = false; + + // Validate and sanitize string arrays (allow, deny, ask, additionalDirectories) + for (const arrayField of ['allow', 'deny', 'ask', 'additionalDirectories'] as const) { + const value = (permissions as Record)[arrayField]; + if (Array.isArray(value)) { + const sanitizedArray = value.filter((item): item is string => typeof item === 'string'); + if (sanitizedArray.length > 0) { + result[arrayField] = sanitizedArray; + hasValidFields = true; + } else { + debugLog(`${LOG_PREFIX} Skipping empty or invalid array field:`, arrayField); + } + } + } + + // Validate defaultMode (must be one of the allowed values) + const defaultMode = (permissions as Record).defaultMode; + if (typeof defaultMode === 'string' && ['ask', 'acceptEdits', 'plan'].includes(defaultMode)) { + result.defaultMode = defaultMode as 'ask' | 'acceptEdits' | 'plan'; + hasValidFields = true; + } else if (defaultMode !== undefined) { + debugLog(`${LOG_PREFIX} Skipping invalid defaultMode:`, defaultMode); + } + + return hasValidFields ? result : undefined; +} + +/** + * Validate and sanitize a parsed JSON object to ensure it has the expected structure for ClaudeCodeSettings. + * Invalid fields are removed, valid fields are kept. + * Returns undefined if the entire object is invalid or empty after sanitization. + */ +function isValidSettings(obj: unknown): obj is ClaudeCodeSettings { + if (!isPlainObject(obj)) { + return false; + } + + // Start with a clean object + const sanitized: ClaudeCodeSettings = {}; + let hasValidFields = false; + + // Validate and sanitize model field + if ('model' in obj) { + if (typeof obj.model === 'string') { + sanitized.model = obj.model; + hasValidFields = true; + } else { + debugLog(`${LOG_PREFIX} Skipping invalid model field:`, typeof obj.model); + } + } + + // Validate and sanitize alwaysThinkingEnabled field + if ('alwaysThinkingEnabled' in obj) { + if (typeof obj.alwaysThinkingEnabled === 'boolean') { + sanitized.alwaysThinkingEnabled = obj.alwaysThinkingEnabled; + hasValidFields = true; + } else { + debugLog(`${LOG_PREFIX} Skipping invalid alwaysThinkingEnabled field:`, typeof obj.alwaysThinkingEnabled); + } + } + + // Validate and sanitize env field + if ('env' in obj) { + const sanitizedEnv = sanitizeEnv(obj.env); + if (sanitizedEnv) { + sanitized.env = sanitizedEnv; + hasValidFields = true; + } else { + debugError(`${LOG_PREFIX} Invalid or empty env field, skipping`); + } + } + + // Validate and sanitize permissions field + if ('permissions' in obj) { + const sanitizedPermissions = sanitizePermissions(obj.permissions); + if (sanitizedPermissions) { + sanitized.permissions = sanitizedPermissions; + hasValidFields = true; + } else { + debugError(`${LOG_PREFIX} Invalid or empty permissions field, skipping`); + } + } + + // If we have at least one valid field, mutate the original object to contain only sanitized fields + if (hasValidFields) { + // Clear the original object and copy sanitized fields + for (const key of Object.keys(obj)) { + delete (obj as Record)[key]; + } + Object.assign(obj, sanitized); + return true; + } + + return false; +} + +/** + * Safely read and parse a JSON settings file. + * Returns undefined if the file doesn't exist or fails to parse. + */ +function readJsonFile(filePath: string): ClaudeCodeSettings | undefined { + if (!existsSync(filePath)) { + debugLog(`${LOG_PREFIX} File not found:`, filePath); + return undefined; + } + + try { + const content = readFileSync(filePath, 'utf-8'); + const parsed = JSON.parse(content); + + if (!isValidSettings(parsed)) { + debugError(`${LOG_PREFIX} Invalid settings structure (expected object):`, filePath); + return undefined; + } + + debugLog(`${LOG_PREFIX} Read settings from:`, filePath); + return parsed; + } catch (error) { + debugError(`${LOG_PREFIX} Failed to parse settings file:`, filePath, error); + return undefined; + } +} + +/** + * Resolve the user-global Claude config directory. + * + * Priority: + * 1. Active Claude profile's configDir (from ClaudeProfileManager) + * 2. CLAUDE_CONFIG_DIR environment variable + * 3. Default: ~/.claude + */ +function getUserConfigDir(): string { + // Try to get configDir from the active Claude profile. + // We use a lazy import to avoid circular dependencies and to handle + // the case where ClaudeProfileManager hasn't been initialized yet. + try { + // Dynamic require to avoid circular dependency at module load time + const { getClaudeProfileManager } = require('../claude-profile-manager'); + const manager = getClaudeProfileManager(); + if (manager.isInitialized()) { + const activeProfile = manager.getActiveProfile(); + if (activeProfile?.configDir) { + const configDir = activeProfile.configDir.startsWith('~/') + || activeProfile.configDir === '~' + ? activeProfile.configDir.replace(/^~/, homedir()) + : activeProfile.configDir; + debugLog(`${LOG_PREFIX} Using active profile configDir:`, configDir); + return configDir; + } + } + } catch { + debugLog(`${LOG_PREFIX} ClaudeProfileManager not available, using fallback`); + } + + // Fall back to CLAUDE_CONFIG_DIR env var + const envConfigDir = process.env.CLAUDE_CONFIG_DIR; + if (envConfigDir) { + debugLog(`${LOG_PREFIX} Using CLAUDE_CONFIG_DIR:`, envConfigDir); + return envConfigDir; + } + + // Default: ~/.claude + const defaultDir = path.join(homedir(), '.claude'); + debugLog(`${LOG_PREFIX} Using default config dir:`, defaultDir); + return defaultDir; +} + +/** + * Read user-global settings. + * Path: {configDir}/settings.json + */ +export function readUserGlobalSettings(): ClaudeCodeSettings | undefined { + const configDir = getUserConfigDir(); + const settingsPath = path.join(configDir, 'settings.json'); + debugLog(`${LOG_PREFIX} Reading user global settings:`, settingsPath); + return readJsonFile(settingsPath); +} + +/** + * Read shared project settings. + * Path: {projectPath}/.claude/settings.json + */ +export function readProjectSharedSettings(projectPath: string): ClaudeCodeSettings | undefined { + const settingsPath = path.join(projectPath, '.claude', 'settings.json'); + debugLog(`${LOG_PREFIX} Reading project shared settings:`, settingsPath); + return readJsonFile(settingsPath); +} + +/** + * Read local project settings (gitignored, user-specific overrides). + * Path: {projectPath}/.claude/settings.local.json + */ +export function readProjectLocalSettings(projectPath: string): ClaudeCodeSettings | undefined { + const settingsPath = path.join(projectPath, '.claude', 'settings.local.json'); + debugLog(`${LOG_PREFIX} Reading project local settings:`, settingsPath); + return readJsonFile(settingsPath); +} + +/** + * Get the platform-specific path for managed settings. + * + * - macOS: /Library/Application Support/ClaudeCode/managed-settings.json + * - Linux: /etc/claude-code/managed-settings.json + * - Windows: C:\Program Files\ClaudeCode\managed-settings.json + */ +function getManagedSettingsPath(): string { + if (isWindows()) { + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + return path.join(programFiles, 'ClaudeCode', 'managed-settings.json'); + } + + if (isMacOS()) { + return '/Library/Application Support/ClaudeCode/managed-settings.json'; + } + + // Linux + return '/etc/claude-code/managed-settings.json'; +} + +/** + * Read managed (system-wide) settings. + * Path: platform-specific (see getManagedSettingsPath) + */ +export function readManagedSettings(): ClaudeCodeSettings | undefined { + const settingsPath = getManagedSettingsPath(); + debugLog(`${LOG_PREFIX} Reading managed settings:`, settingsPath); + return readJsonFile(settingsPath); +} + +/** + * Read all settings levels and return the full hierarchy with merged result. + * + * @param projectPath - Optional project path. If not provided, only user-global + * and managed settings are read. + * @returns The full settings hierarchy including the merged result. + */ +export function readAllSettings(projectPath?: string): ClaudeCodeSettingsHierarchy { + const validProjectPath = projectPath && projectPath.trim().length > 0 ? projectPath : undefined; + + debugLog( + `${LOG_PREFIX} Reading all settings`, + validProjectPath ? { projectPath: validProjectPath } : undefined + ); + + const user = readUserGlobalSettings(); + const projectShared = validProjectPath ? readProjectSharedSettings(validProjectPath) : undefined; + const projectLocal = validProjectPath ? readProjectLocalSettings(validProjectPath) : undefined; + const managed = readManagedSettings(); + + const hierarchy: ClaudeCodeSettingsHierarchy = { + user, + projectShared, + projectLocal, + managed, + merged: {} as ClaudeCodeSettings, // placeholder, replaced below + }; + + hierarchy.merged = mergeClaudeCodeSettings(hierarchy); + + debugLog(`${LOG_PREFIX} Merged settings result:`, hierarchy.merged); + return hierarchy; +} diff --git a/apps/frontend/src/main/claude-code-settings/types.ts b/apps/frontend/src/main/claude-code-settings/types.ts new file mode 100644 index 00000000..2f73b1e3 --- /dev/null +++ b/apps/frontend/src/main/claude-code-settings/types.ts @@ -0,0 +1,53 @@ +/** + * Claude Code CLI Settings Types + * + * TypeScript interfaces for Claude Code's settings.json files. + * These settings are read from up to 4 levels (user global, shared project, + * local project, managed) and merged with a defined precedence order. + */ + +/** + * Permission configuration for Claude Code tool usage. + */ +export interface ClaudeCodePermissions { + /** Tool patterns that are always allowed without prompting */ + allow?: string[]; + /** Tool patterns that are always denied */ + deny?: string[]; + /** Tool patterns that require user confirmation */ + ask?: string[]; + /** Default permission mode when no specific rule matches */ + defaultMode?: 'ask' | 'acceptEdits' | 'plan'; + /** Additional directories Claude Code can access */ + additionalDirectories?: string[]; +} + +/** + * A single level of Claude Code settings, as read from one settings file. + * All fields are optional since any given file may only set a subset. + */ +export interface ClaudeCodeSettings { + permissions?: ClaudeCodePermissions; + /** Model override (e.g. "claude-sonnet-4-5-20250929") */ + model?: string; + /** Whether to enable extended thinking by default */ + alwaysThinkingEnabled?: boolean; + /** Environment variables to inject into agent processes */ + env?: Record; +} + +/** + * The full hierarchy of settings from all levels, plus the merged result. + */ +export interface ClaudeCodeSettingsHierarchy { + /** User-global settings from ~/.claude/settings.json */ + user?: ClaudeCodeSettings; + /** Shared project settings from {projectPath}/.claude/settings.json */ + projectShared?: ClaudeCodeSettings; + /** Local project settings from {projectPath}/.claude/settings.local.json */ + projectLocal?: ClaudeCodeSettings; + /** Platform-managed settings from system-wide location */ + managed?: ClaudeCodeSettings; + /** Final merged result (user < projectShared < projectLocal < managed) */ + merged: ClaudeCodeSettings; +} diff --git a/apps/frontend/src/main/terminal/terminal-lifecycle.ts b/apps/frontend/src/main/terminal/terminal-lifecycle.ts index 39b7728d..6340885a 100644 --- a/apps/frontend/src/main/terminal/terminal-lifecycle.ts +++ b/apps/frontend/src/main/terminal/terminal-lifecycle.ts @@ -18,6 +18,7 @@ import type { import { isWindows } from '../platform'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { safeSendToRenderer } from '../ipc-handlers/utils'; +import { getClaudeCodeEnv } from '../claude-code-settings'; /** * Options for terminal restoration @@ -57,8 +58,17 @@ export async function createTerminal( // For auth terminals, don't inject existing OAuth token - we want a fresh login const profileEnv = skipOAuthToken ? {} : PtyManager.getActiveProfileEnv(); - // Merge custom environment variables (e.g., CLAUDE_CONFIG_DIR for auth terminals) - const mergedEnv = customEnv ? { ...profileEnv, ...customEnv } : profileEnv; + // Read env vars from Claude Code CLI settings files (.claude/settings.json hierarchy) + const claudeCodeEnv = getClaudeCodeEnv(projectPath); + if (Object.keys(claudeCodeEnv).length > 0) { + debugLog('[TerminalLifecycle] Injecting Claude Code settings env vars:', Object.keys(claudeCodeEnv)); + } + + // Merge environment variables (lowest to highest precedence): + // 1. Claude Code settings env (from settings.json hierarchy) + // 2. Profile env (CLAUDE_CONFIG_DIR, CLAUDE_CODE_OAUTH_TOKEN) + // 3. Custom env from TerminalCreateOptions + const mergedEnv = { ...claudeCodeEnv, ...profileEnv, ...(customEnv || {}) }; if (mergedEnv.CLAUDE_CODE_OAUTH_TOKEN) { debugLog('[TerminalLifecycle] Injecting OAuth token from active profile');