fix(auth): harden shared-context isolation and config safety

This commit is contained in:
Tam Nhu Tran committed 2026-02-25 04:19:28 +07:00
1 parent 0b070a3f34
commit 0309630193
18 files changed
+695 -197

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
* Normalize context group names so paths and config stay consistent.
*/
export function normalizeContextGroupName(value: string): string {
return value.trim().toLowerCase();
return value.trim().toLowerCase().replace(/\s+/g, '-');
}
/**
+5
View File
@@ -14,6 +14,7 @@
import ProfileRegistry from './profile-registry';
import { InstanceManager } from '../management/instance-manager';
import { initUI, header, subheader, color, dim, warn, fail } from '../utils/ui';
import { MAX_CONTEXT_GROUP_LENGTH } from './account-context';
import packageJson from '../../package.json';
// Import command handlers from modular structure
@@ -127,6 +128,10 @@ class AuthCommands {
console.log(
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
);
console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`);
console.log(
` ${color('context_group', 'path')} must be non-empty and <= ${MAX_CONTEXT_GROUP_LENGTH} chars in shared mode.`
);
console.log('');
}
+57
View File
@@ -0,0 +1,57 @@
const AMBIENT_PROVIDER_PREFIXES = [
'ANTHROPIC_',
'OPENAI_',
'GOOGLE_',
'GEMINI_',
'MINIMAX_',
'QWEN_',
'DEEPSEEK_',
'KIMI_',
'AZURE_',
'OLLAMA_',
'OPENROUTER_',
'XAI_',
'MISTRAL_',
'COHERE_',
'PERPLEXITY_',
'TOGETHER_',
'FIREWORKS_',
];
const AMBIENT_PROVIDER_EXACT_KEYS = new Set([
'OPENROUTER_API_KEY',
'OPENROUTER_KEY',
'XAI_API_KEY',
'MISTRAL_API_KEY',
'COHERE_API_KEY',
]);
const AMBIENT_PROVIDER_SUFFIXES = [
'_API_KEY',
'_AUTH_TOKEN',
'_ACCESS_TOKEN',
'_SECRET_KEY',
'_API_TOKEN',
'_BEARER_TOKEN',
'_SESSION_TOKEN',
];
export function stripAmbientProviderCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const sanitized: NodeJS.ProcessEnv = { ...env };
for (const envKey of Object.keys(sanitized)) {
const normalizedKey = envKey.toUpperCase();
if (normalizedKey === 'CLAUDE_CONFIG_DIR') {
continue;
}
if (
AMBIENT_PROVIDER_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix)) ||
AMBIENT_PROVIDER_EXACT_KEYS.has(normalizedKey) ||
AMBIENT_PROVIDER_SUFFIXES.some((suffix) => normalizedKey.endsWith(suffix))
) {
delete sanitized[envKey];
}
}
return sanitized;
}
+26 -60
View File
@@ -20,52 +20,12 @@ import {
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, parseArgs } from './types';
import { stripAmbientProviderCredentials } from './create-command-env';
function sanitizeProfileNameForInstance(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
}
const AMBIENT_PROVIDER_PREFIXES = [
'ANTHROPIC_',
'OPENAI_',
'GOOGLE_',
'GEMINI_',
'MINIMAX_',
'QWEN_',
'DEEPSEEK_',
'KIMI_',
'AZURE_',
'OLLAMA_',
];
const AMBIENT_PROVIDER_EXACT_KEYS = new Set([
'OPENROUTER_API_KEY',
'OPENROUTER_KEY',
'XAI_API_KEY',
'MISTRAL_API_KEY',
'COHERE_API_KEY',
]);
const AMBIENT_PROVIDER_SUFFIXES = ['_API_KEY', '_AUTH_TOKEN', '_ACCESS_TOKEN', '_SECRET_KEY'];
function stripAmbientProviderCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const sanitized: NodeJS.ProcessEnv = { ...env };
for (const envKey of Object.keys(sanitized)) {
if (envKey === 'CLAUDE_CONFIG_DIR') {
continue;
}
if (
AMBIENT_PROVIDER_PREFIXES.some((prefix) => envKey.startsWith(prefix)) ||
AMBIENT_PROVIDER_EXACT_KEYS.has(envKey) ||
AMBIENT_PROVIDER_SUFFIXES.some((suffix) => envKey.endsWith(suffix))
) {
delete sanitized[envKey];
}
}
return sanitized;
}
/**
* Handle the create command
*/
@@ -74,9 +34,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
const { profileName, force, shareContext, contextGroup, unknownFlags } = parseArgs(args);
if (unknownFlags && unknownFlags.length > 0) {
const unknownList = unknownFlags.join(', ');
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
console.log(fail(`Unknown option(s): ${unknownList}`));
console.log('');
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', 'command')}`
);
console.log(`Help: ${color('ccs auth --help', 'command')}`);
console.log('');
exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR);
}
@@ -135,15 +100,17 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
const contextPolicy = resolvedContext.policy;
const contextMetadata = policyToAccountContextMetadata(contextPolicy);
const useUnifiedConfig = isUnifiedMode();
const createdProfile = useUnifiedConfig ? !existsUnified : !existsLegacy;
const previousLegacyProfile: ProfileMetadata | undefined =
!useUnifiedConfig && existsLegacy ? ctx.registry.getProfile(profileName) : undefined;
const previousUnifiedProfile =
useUnifiedConfig && existsUnified
? ctx.registry.getAllAccountsUnified()[profileName]
: undefined;
const profileExistedBeforeCreate = existsLegacy || existsUnified;
const createdUnifiedProfile = useUnifiedConfig && !existsUnified;
const createdLegacyProfile = !useUnifiedConfig && !existsLegacy;
const previousLegacyProfile: ProfileMetadata | undefined = existsLegacy
? ctx.registry.getProfile(profileName)
: undefined;
const previousUnifiedProfile = existsUnified
? ctx.registry.getAllAccountsUnified()[profileName]
: undefined;
const previousContextPolicy =
!createdProfile && (previousUnifiedProfile || previousLegacyProfile)
profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile)
? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile)
: undefined;
@@ -160,22 +127,21 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
const rollbackMetadata = (): void => {
try {
if (useUnifiedConfig) {
if (createdProfile) {
if (createdUnifiedProfile) {
if (ctx.registry.hasAccountUnified(profileName)) {
ctx.registry.removeAccountUnified(profileName);
}
} else if (previousUnifiedProfile) {
ctx.registry.updateAccountUnified(profileName, previousUnifiedProfile);
}
return;
}
if (createdProfile) {
if (ctx.registry.hasProfile(profileName)) {
ctx.registry.deleteProfile(profileName);
} else {
if (createdLegacyProfile) {
if (ctx.registry.hasProfile(profileName)) {
ctx.registry.deleteProfile(profileName);
}
} else if (previousLegacyProfile) {
ctx.registry.updateProfile(profileName, previousLegacyProfile);
}
} else if (previousLegacyProfile) {
ctx.registry.updateProfile(profileName, previousLegacyProfile);
}
} catch {
// Best-effort rollback to avoid leaving stale accounts after failed login.
@@ -190,7 +156,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
rollbackMetadata();
if (createdProfile) {
if (!profileExistedBeforeCreate) {
try {
ctx.instanceMgr.deleteInstance(profileName);
} catch {
+28 -4
View File
@@ -8,6 +8,7 @@ import {
} from '../config/unified-config-loader';
import type { AccountConfig } from '../config/unified-config-types';
import { getCcsDir } from '../utils/config-manager';
import { isValidContextGroupName, normalizeContextGroupName } from './account-context';
/**
* Profile Registry (Simplified)
@@ -51,13 +52,31 @@ export class ProfileRegistry {
this.profilesPath = path.join(getCcsDir(), 'profiles.json');
}
private normalizeContextGroupValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = normalizeContextGroupName(value);
if (normalized.length === 0 || !isValidContextGroupName(normalized)) {
return undefined;
}
return normalized;
}
private normalizeLegacyProfileMetadata(metadata: ProfileMetadata): ProfileMetadata {
const normalized: ProfileMetadata = { ...metadata };
if (normalized.context_mode !== 'shared') {
delete normalized.context_group;
} else if (!normalized.context_group || normalized.context_group.trim().length === 0) {
delete normalized.context_group;
} else {
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
if (normalizedGroup) {
normalized.context_group = normalizedGroup;
} else {
delete normalized.context_group;
}
}
return normalized;
@@ -68,8 +87,13 @@ export class ProfileRegistry {
if (normalized.context_mode !== 'shared') {
delete normalized.context_group;
} else if (!normalized.context_group || normalized.context_group.trim().length === 0) {
delete normalized.context_group;
} else {
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
if (normalizedGroup) {
normalized.context_group = normalizedGroup;
} else {
delete normalized.context_group;
}
}
return normalized;