feat(terminal): read Claude Code CLI settings and inject env vars into PTY sessions (#1750)

* feat(terminal): read Claude Code CLI settings and inject env vars into PTY sessions

Add a claude-code-settings module that reads Claude Code's settings.json
files from all 4 hierarchy levels (user global, shared project, local
project, managed/enterprise) and merges them with correct precedence.
The merged env vars are injected into terminal PTY sessions so that
Claude Code CLI respects user-configured environment variables.

- Reader supports active profile configDir, CLAUDE_CONFIG_DIR, and
  platform-specific managed settings paths (macOS/Linux/Windows)
- Merger handles scalar overrides, env deep merge, and permission
  array concatenation with deduplication
- 51 tests covering reader, merger, and convenience API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(test): use cross-platform paths in reader tests for Windows CI

The reader tests hardcoded Unix-style forward-slash paths in mock
expectations and mockImplementation callbacks. On Windows, path.join
produces backslashes, so path comparisons failed (10 test failures).

Fix: use path.join() to construct expected paths so they match the
platform's native separator on both Unix and Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): add env var blocklist and runtime validation for settings

Address PR review findings:

[HIGH] Add env-sanitizer.ts with blocklist for dangerous environment
variables (LD_PRELOAD, DYLD_INSERT_LIBRARIES, NODE_OPTIONS, PYTHONSTARTUP,
BASH_ENV, etc.) that could enable supply chain attacks via malicious
.claude/settings.json files. Warning-level vars (PATH, SHELL) are allowed
but logged. Sanitizer is wired into merger.ts to filter before injection.

[MEDIUM] Enhance isValidSettings() with field-level runtime validation:
env must be Record<string, string>, model must be string,
alwaysThinkingEnabled must be boolean, permissions must have correct
structure. Invalid fields are sanitized (removed) rather than rejecting
the entire settings object.

[LOW] Remove unnecessary console.warn spies from reader tests — production
code uses debugError which is already mocked.

Also fixes cross-platform path issues in reader tests (Windows CI).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* security docs

* fix(security): expand env blocklist, fix docs, add bypass tests

[MEDIUM] Add 10 missing dangerous env vars to blocklist:
ZDOTDIR, INPUTRC (shell hijacking), JAVA_TOOL_OPTIONS,
_JAVA_OPTIONS, MAVEN_OPTS, GRADLE_OPTS (JVM injection),
PYTHONUSERBASE, NPM_CONFIG_PREFIX, YARN_RC_FILENAME,
COMPOSER_HOME (package manager hijacking).

[LOW] Fix SECURITY.md to clarify that dangerous vars are blocked
from ALL levels unconditionally — trust level only affects
PATH/SHELL warning behavior.

[LOW] Add encoding bypass resistance tests: trailing whitespace,
null bytes, and Unicode homoglyphs. Documents that JS string
handling prevents these bypass vectors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-02-06 11:04:42 +01:00
committed by GitHub
parent 2c2a8a7545
commit 152e540933
11 changed files with 3180 additions and 2 deletions
@@ -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
@@ -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<string, string>)).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');
});
});
});
@@ -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({});
});
});
@@ -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',
});
});
});
});
@@ -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' },
});
});
});
});
@@ -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<string, string> | undefined,
sourceLevel: 'user' | 'projectShared' | 'projectLocal' | 'managed' = 'user'
): Record<string, string> {
if (!env || typeof env !== 'object') {
return {};
}
const sanitized: Record<string, string> = {};
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();
}
@@ -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<string, string> {
const hierarchy = _readAllSettings(projectPath);
return hierarchy.merged.env ?? {};
}
@@ -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<string, string> | undefined,
higher: Record<string, string> | undefined,
lowerLevel: 'user' | 'projectShared' | 'projectLocal' | 'managed' = 'user',
higherLevel: 'user' | 'projectShared' | 'projectLocal' | 'managed' = 'user'
): Record<string, string> | 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;
}
@@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Validate and sanitize the env field to ensure it's a Record<string, string>.
* Returns undefined if the field is invalid or empty after sanitization.
*/
function sanitizeEnv(env: unknown): Record<string, string> | undefined {
if (!isPlainObject(env)) {
return undefined;
}
const sanitized: Record<string, string> = {};
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<string, unknown>)[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<string, unknown>).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<string, unknown>)[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;
}
@@ -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<string, string>;
}
/**
* 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;
}
@@ -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');