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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user