mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 10:14:36 +00:00
fix(auth): harden shared-context isolation and config safety
This commit is contained in:
1 parent
0b070a3f34
commit
0309630193
18 files changed
+695
-197
No files matched your search
@@ -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, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user