fix(security): harden command and path handling
This commit is contained in:
parent
8edff2f0c3
commit
c357793c2f
4 changed files with 502 additions and 167 deletions
|
|
@ -52,6 +52,7 @@ import {
|
|||
type WorkspaceTrustProvider,
|
||||
type WorkspaceTrustWorkspace,
|
||||
} from '@features/workspace-trust/main';
|
||||
import { validateTeamName } from '@main/ipc/guards';
|
||||
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
||||
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
|
||||
import { prepareAgentChildProcessWritableEnv } from '@main/services/runtime/agentChildProcessPreflight';
|
||||
|
|
@ -636,8 +637,31 @@ const TEAMMATE_BOOTSTRAP_PROOF_TOKEN_ENV = 'CLAUDE_CODE_BOOTSTRAP_PROOF_TOKEN';
|
|||
const NATIVE_APP_MANAGED_BOOTSTRAP_CONTEXT_ENV =
|
||||
'CLAUDE_CODE_NATIVE_APP_MANAGED_BOOTSTRAP_CONTEXT_PATH';
|
||||
|
||||
function resolveValidatedTeamStoragePath(
|
||||
basePath: string,
|
||||
teamName: string,
|
||||
...segments: string[]
|
||||
): string {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid || !validated.value) {
|
||||
throw new Error(validated.error ?? 'Invalid teamName for team storage path');
|
||||
}
|
||||
|
||||
const root = path.resolve(basePath);
|
||||
const teamDir = path.resolve(root, validated.value);
|
||||
if (!isPathWithinRoot(teamDir, root)) {
|
||||
throw new Error(`Invalid team storage path for "${validated.value}"`);
|
||||
}
|
||||
|
||||
const target = path.resolve(teamDir, ...segments);
|
||||
if (!isPathWithinRoot(target, teamDir)) {
|
||||
throw new Error(`Invalid team storage child path for "${validated.value}"`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function getTeamRuntimeEventsDir(teamName: string): string {
|
||||
return path.join(getTeamsBasePath(), teamName, 'runtime');
|
||||
return resolveValidatedTeamStoragePath(getTeamsBasePath(), teamName, 'runtime');
|
||||
}
|
||||
|
||||
function isProcessBootstrapTransportDiagnostic(value: unknown): value is string {
|
||||
|
|
@ -4993,7 +5017,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
private readPersistedTeamProcessRows(teamName: string): unknown[] | null {
|
||||
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
|
||||
const processesPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'processes.json');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(processesPath, 'utf8')) as unknown;
|
||||
|
|
@ -14702,7 +14726,7 @@ export class TeamProvisioningService {
|
|||
bootstrapContextHash?: string;
|
||||
bootstrapBriefingHash?: string;
|
||||
}): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), input.teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), input.teamName, 'config.json');
|
||||
const raw = await tryReadRegularFileUtf8(configPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
maxBytes: TEAM_CONFIG_MAX_BYTES,
|
||||
|
|
@ -19890,7 +19914,7 @@ export class TeamProvisioningService {
|
|||
try {
|
||||
const teamsBasePathsToProbe = getTeamsBasePathsToProbe();
|
||||
for (const probe of teamsBasePathsToProbe) {
|
||||
const configPath = path.join(probe.basePath, request.teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(probe.basePath, request.teamName, 'config.json');
|
||||
if (await this.pathExists(configPath)) {
|
||||
const suffix = probe.location === 'configured' ? '' : ` (found under ${probe.basePath})`;
|
||||
throw new Error(`Team already exists${suffix}`);
|
||||
|
|
@ -20199,8 +20223,8 @@ export class TeamProvisioningService {
|
|||
// member_briefing intentionally reads canonical team metadata/inboxes, so
|
||||
// createTeam must materialize those files before building the bootstrap spec.
|
||||
emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn');
|
||||
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
||||
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
||||
const teamDir = this.resolveSafeTeamStoragePath(getTeamsBasePath(), request.teamName);
|
||||
const tasksDir = this.resolveSafeTeamStoragePath(getTasksBasePath(), request.teamName);
|
||||
await fs.promises.mkdir(teamDir, { recursive: true });
|
||||
await fs.promises.mkdir(tasksDir, { recursive: true });
|
||||
await this.teamMetaStore.writeMeta(request.teamName, {
|
||||
|
|
@ -20297,8 +20321,8 @@ export class TeamProvisioningService {
|
|||
}).catch(() => undefined);
|
||||
}
|
||||
await this.teamMetaStore.deleteMeta(request.teamName).catch(() => {});
|
||||
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
||||
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
||||
const teamDir = this.resolveSafeTeamStoragePath(getTeamsBasePath(), request.teamName);
|
||||
const tasksDir = this.resolveSafeTeamStoragePath(getTasksBasePath(), request.teamName);
|
||||
await fs.promises.rm(teamDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.promises.rm(tasksDir, { recursive: true, force: true }).catch(() => {});
|
||||
await removeDeterministicBootstrapSpecFile(run.bootstrapSpecPath).catch(() => {});
|
||||
|
|
@ -20402,8 +20426,8 @@ export class TeamProvisioningService {
|
|||
} catch (error) {
|
||||
// Clean up pre-saved meta files if spawn failed (instant failure, not transient)
|
||||
await this.teamMetaStore.deleteMeta(request.teamName).catch(() => {});
|
||||
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
||||
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
||||
const teamDir = this.resolveSafeTeamStoragePath(getTeamsBasePath(), request.teamName);
|
||||
const tasksDir = this.resolveSafeTeamStoragePath(getTasksBasePath(), request.teamName);
|
||||
await fs.promises.rm(teamDir, { recursive: true, force: true }).catch(() => {});
|
||||
await fs.promises.rm(tasksDir, { recursive: true, force: true }).catch(() => {});
|
||||
await removeDeterministicBootstrapSpecFile(run.bootstrapSpecPath).catch(() => {});
|
||||
|
|
@ -20510,7 +20534,7 @@ export class TeamProvisioningService {
|
|||
): Promise<TeamCreateResponse> {
|
||||
const teamsBasePathsToProbe = getTeamsBasePathsToProbe();
|
||||
for (const probe of teamsBasePathsToProbe) {
|
||||
const configPath = path.join(probe.basePath, request.teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(probe.basePath, request.teamName, 'config.json');
|
||||
if (await this.pathExists(configPath)) {
|
||||
const suffix = probe.location === 'configured' ? '' : ` (found under ${probe.basePath})`;
|
||||
throw new Error(`Team already exists${suffix}`);
|
||||
|
|
@ -20529,8 +20553,8 @@ export class TeamProvisioningService {
|
|||
leadProviderId: launchRequest.providerId,
|
||||
members: materialized.members,
|
||||
});
|
||||
const teamDir = path.join(getTeamsBasePath(), launchRequest.teamName);
|
||||
const tasksDir = path.join(getTasksBasePath(), launchRequest.teamName);
|
||||
const teamDir = this.resolveSafeTeamStoragePath(getTeamsBasePath(), launchRequest.teamName);
|
||||
const tasksDir = this.resolveSafeTeamStoragePath(getTasksBasePath(), launchRequest.teamName);
|
||||
await fs.promises.mkdir(teamDir, { recursive: true });
|
||||
await fs.promises.mkdir(tasksDir, { recursive: true });
|
||||
await this.teamMetaStore.writeMeta(launchRequest.teamName, {
|
||||
|
|
@ -20568,7 +20592,7 @@ export class TeamProvisioningService {
|
|||
request: TeamLaunchRequest,
|
||||
onProgress: (progress: TeamProvisioningProgress) => void
|
||||
): Promise<TeamLaunchResponse> {
|
||||
const configPath = path.join(getTeamsBasePath(), request.teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), request.teamName, 'config.json');
|
||||
const configRaw = await tryReadRegularFileUtf8(configPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
maxBytes: TEAM_CONFIG_MAX_BYTES,
|
||||
|
|
@ -20842,7 +20866,7 @@ export class TeamProvisioningService {
|
|||
request: TeamCreateRequest,
|
||||
members: TeamCreateRequest['members']
|
||||
): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), request.teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), request.teamName, 'config.json');
|
||||
const config: TeamConfig = {
|
||||
name: request.displayName?.trim() || request.teamName,
|
||||
description: request.description,
|
||||
|
|
@ -21078,7 +21102,7 @@ export class TeamProvisioningService {
|
|||
|
||||
try {
|
||||
// Verify config.json exists — team must already be provisioned
|
||||
const configPath = path.join(getTeamsBasePath(), request.teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), request.teamName, 'config.json');
|
||||
const configRaw = await tryReadRegularFileUtf8(configPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
maxBytes: TEAM_CONFIG_MAX_BYTES,
|
||||
|
|
@ -23927,7 +23951,12 @@ export class TeamProvisioningService {
|
|||
member: string,
|
||||
messages: { messageId: string }[]
|
||||
): Promise<void> {
|
||||
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`);
|
||||
const inboxDir = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'inboxes');
|
||||
const safeMemberName = this.normalizeSafeInboxBaseName(member);
|
||||
if (!safeMemberName) {
|
||||
return;
|
||||
}
|
||||
const inboxPath = this.resolveSafeInboxFilePath(inboxDir, `${safeMemberName}.json`);
|
||||
|
||||
await withFileLock(inboxPath, async () => {
|
||||
await withInboxLock(inboxPath, async () => {
|
||||
|
|
@ -24060,7 +24089,7 @@ export class TeamProvisioningService {
|
|||
* one-shot subagent instead of a persistent teammate.
|
||||
*/
|
||||
private async getRegisteredTeamMemberNames(teamName: string): Promise<Set<string> | null> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'config.json');
|
||||
try {
|
||||
const raw = await tryReadRegularFileUtf8(configPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
|
|
@ -24089,7 +24118,7 @@ export class TeamProvisioningService {
|
|||
const registeredNames = await this.getRegisteredTeamMemberNames(run.teamName);
|
||||
if (!registeredNames) {
|
||||
try {
|
||||
await fs.promises.access(path.join(getTeamsBasePath(), run.teamName));
|
||||
await fs.promises.access(this.resolveSafeTeamStoragePath(getTeamsBasePath(), run.teamName));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
|
@ -28946,7 +28975,12 @@ export class TeamProvisioningService {
|
|||
teamName: string,
|
||||
leadName: string
|
||||
): Promise<LeadInboxLaunchReconcileMessage[]> {
|
||||
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${leadName}.json`);
|
||||
const inboxDir = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'inboxes');
|
||||
const safeLeadName = this.normalizeSafeInboxBaseName(leadName);
|
||||
if (!safeLeadName) {
|
||||
return [];
|
||||
}
|
||||
const inboxPath = this.resolveSafeInboxFilePath(inboxDir, `${safeLeadName}.json`);
|
||||
try {
|
||||
const raw = await tryReadRegularFileUtf8(inboxPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
|
|
@ -29772,7 +29806,7 @@ export class TeamProvisioningService {
|
|||
return { snapshot: null, statuses: {} };
|
||||
}
|
||||
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'config.json');
|
||||
let configMembers = new Set<string>();
|
||||
let configBootstrapRunIds = new Map<string, string>();
|
||||
let leadName = 'team-lead';
|
||||
|
|
@ -31714,7 +31748,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
private readPersistedTeamProjectPath(teamName: string): string | null {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'config.json');
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { projectPath?: unknown };
|
||||
|
|
@ -31726,7 +31760,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
private readPersistedRuntimeMembers(teamName: string): PersistedRuntimeMemberLike[] {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'config.json');
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { members?: unknown };
|
||||
|
|
@ -33778,8 +33812,8 @@ export class TeamProvisioningService {
|
|||
toolNames = ['Edit', 'Write', 'NotebookEdit', 'Bash', 'Read', 'Grep', 'Glob'];
|
||||
}
|
||||
if (toolNames.length > 0) {
|
||||
const settingsPath = path.join(projectCwd, '.claude', 'settings.local.json');
|
||||
try {
|
||||
const settingsPath = await this.resolveProjectClaudeSettingsPath(projectCwd);
|
||||
await this.addPermissionRulesToSettings(settingsPath, toolNames, 'allow');
|
||||
logger.info(
|
||||
`[${run.teamName}] Applied setMode "${mode}" for ${agentId}: ${toolNames.join(', ')} in ${settingsPath}`
|
||||
|
|
@ -33818,13 +33852,9 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const behavior = suggestion.behavior ?? 'allow';
|
||||
// FACT: observed destinations are "localSettings" (project-level .claude/settings.local.json)
|
||||
const settingsPath =
|
||||
suggestion.destination === 'localSettings'
|
||||
? path.join(projectCwd, '.claude', 'settings.local.json')
|
||||
: path.join(projectCwd, '.claude', 'settings.local.json'); // default to local
|
||||
|
||||
try {
|
||||
// FACT: observed destinations are "localSettings"; default to project local settings.
|
||||
const settingsPath = await this.resolveProjectClaudeSettingsPath(projectCwd);
|
||||
await this.addPermissionRulesToSettings(settingsPath, toolNames, behavior);
|
||||
logger.info(
|
||||
`[${run.teamName}] Added permission rules for ${agentId}: ${toolNames.join(', ')} -> ${behavior} in ${settingsPath}`
|
||||
|
|
@ -33957,6 +33987,94 @@ export class TeamProvisioningService {
|
|||
return typeof firstQuestion?.question === 'string' ? { [firstQuestion.question]: message } : {};
|
||||
}
|
||||
|
||||
private resolveSafeTeamStoragePath(
|
||||
basePath: string,
|
||||
teamName: string,
|
||||
...segments: string[]
|
||||
): string {
|
||||
return resolveValidatedTeamStoragePath(basePath, teamName, ...segments);
|
||||
}
|
||||
|
||||
private async resolveProjectClaudeSettingsPath(projectCwd: string): Promise<string> {
|
||||
if (!path.isAbsolute(projectCwd)) {
|
||||
throw new Error('Project cwd must be an absolute path');
|
||||
}
|
||||
|
||||
const projectRoot = path.resolve(projectCwd);
|
||||
let projectStat: fs.Stats;
|
||||
try {
|
||||
projectStat = await fs.promises.stat(projectRoot);
|
||||
} catch {
|
||||
throw new Error(`Project cwd does not exist: ${projectCwd}`);
|
||||
}
|
||||
if (!projectStat.isDirectory()) {
|
||||
throw new Error(`Project cwd is not a directory: ${projectCwd}`);
|
||||
}
|
||||
|
||||
const realProjectRoot = await fs.promises.realpath(projectRoot);
|
||||
const claudeDir = path.join(realProjectRoot, '.claude');
|
||||
let claudeDirStat: fs.Stats | null = null;
|
||||
try {
|
||||
claudeDirStat = await fs.promises.lstat(claudeDir);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (claudeDirStat && !claudeDirStat.isDirectory() && !claudeDirStat.isSymbolicLink()) {
|
||||
throw new Error('Project .claude path is not a directory');
|
||||
}
|
||||
if (!claudeDirStat) {
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const realClaudeDir = await fs.promises.realpath(claudeDir);
|
||||
if (!isPathWithinRoot(realClaudeDir, realProjectRoot)) {
|
||||
throw new Error('Project .claude directory resolves outside project cwd');
|
||||
}
|
||||
const realClaudeStat = await fs.promises.stat(realClaudeDir);
|
||||
if (!realClaudeStat.isDirectory()) {
|
||||
throw new Error('Project .claude path is not a directory');
|
||||
}
|
||||
|
||||
return path.join(claudeDir, 'settings.local.json');
|
||||
}
|
||||
|
||||
private normalizeSafeInboxBaseName(baseName: string): string | null {
|
||||
const trimmed = baseName.trim();
|
||||
return /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
private isSafeInboxJsonFileName(fileName: string): boolean {
|
||||
return (
|
||||
path.basename(fileName) === fileName &&
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}\.json$/.test(fileName)
|
||||
);
|
||||
}
|
||||
|
||||
private resolveSafeInboxFilePath(inboxDir: string, fileName: string): string {
|
||||
if (!this.isSafeInboxJsonFileName(fileName)) {
|
||||
throw new Error(`Invalid inbox file name: ${fileName}`);
|
||||
}
|
||||
const resolved = path.resolve(inboxDir, fileName);
|
||||
if (!isPathWithinRoot(resolved, inboxDir)) {
|
||||
throw new Error(`Invalid inbox file path: ${fileName}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private isSafeAutoSuffixedInboxDuplicate(fileName: string, baseName: string): boolean {
|
||||
if (!this.isSafeInboxJsonFileName(fileName)) {
|
||||
return false;
|
||||
}
|
||||
const prefix = `${baseName}-`;
|
||||
if (!fileName.startsWith(prefix) || !fileName.endsWith('.json')) {
|
||||
return false;
|
||||
}
|
||||
const suffix = fileName.slice(prefix.length, -'.json'.length);
|
||||
return /^\d+$/.test(suffix);
|
||||
}
|
||||
/**
|
||||
* Safely add tool names to the permissions.allow (or deny) array in a Claude settings file.
|
||||
* Creates the file and parent directories if they don't exist.
|
||||
|
|
@ -34015,8 +34133,8 @@ export class TeamProvisioningService {
|
|||
teamName: string,
|
||||
projectCwd: string
|
||||
): Promise<void> {
|
||||
const settingsPath = path.join(projectCwd, '.claude', 'settings.local.json');
|
||||
try {
|
||||
const settingsPath = await this.resolveProjectClaudeSettingsPath(projectCwd);
|
||||
const allTools = [
|
||||
...AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
|
||||
'Edit',
|
||||
|
|
@ -34109,7 +34227,7 @@ export class TeamProvisioningService {
|
|||
// Best-effort: detect CLI-suffixed member names (alice-2, bob-2) that indicate
|
||||
// a stale config.json was present during launch (double-launch race).
|
||||
try {
|
||||
const postLaunchConfigPath = path.join(getTeamsBasePath(), run.teamName, 'config.json');
|
||||
const postLaunchConfigPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), run.teamName, 'config.json');
|
||||
const raw = await tryReadRegularFileUtf8(postLaunchConfigPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
maxBytes: TEAM_CONFIG_MAX_BYTES,
|
||||
|
|
@ -35169,9 +35287,12 @@ export class TeamProvisioningService {
|
|||
* Emits progress updates as team files appear (config, inboxes, tasks).
|
||||
*/
|
||||
private startFilesystemMonitor(run: ProvisioningRun, request: TeamCreateRequest): void {
|
||||
const configuredTeamDir = path.join(getTeamsBasePath(), run.teamName);
|
||||
const defaultTeamDir = path.join(getAutoDetectedClaudeBasePath(), 'teams', run.teamName);
|
||||
const tasksDir = path.join(getTasksBasePath(), run.teamName);
|
||||
const configuredTeamDir = this.resolveSafeTeamStoragePath(getTeamsBasePath(), run.teamName);
|
||||
const defaultTeamDir = this.resolveSafeTeamStoragePath(
|
||||
path.join(getAutoDetectedClaudeBasePath(), 'teams'),
|
||||
run.teamName
|
||||
);
|
||||
const tasksDir = this.resolveSafeTeamStoragePath(getTasksBasePath(), run.teamName);
|
||||
const primaryProvisioningMembers = Array.isArray(run.effectiveMembers)
|
||||
? run.effectiveMembers
|
||||
: request.members;
|
||||
|
|
@ -35442,9 +35563,9 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
if (code === 0) {
|
||||
const configuredConfigPath = path.join(getTeamsBasePath(), run.teamName, 'config.json');
|
||||
const configuredConfigPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), run.teamName, 'config.json');
|
||||
const defaultTeamsBasePath = path.join(getAutoDetectedClaudeBasePath(), 'teams');
|
||||
const defaultConfigPath = path.join(defaultTeamsBasePath, run.teamName, 'config.json');
|
||||
const defaultConfigPath = this.resolveSafeTeamStoragePath(defaultTeamsBasePath, run.teamName, 'config.json');
|
||||
const combinedLogs = buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer);
|
||||
const cleanupHint = logsSuggestShutdownOrCleanup(combinedLogs)
|
||||
? ' CLI output suggests the team was shut down / cleaned up, so no persisted config was left on disk.'
|
||||
|
|
@ -35492,7 +35613,7 @@ export class TeamProvisioningService {
|
|||
): Promise<ValidConfigProbeResult> {
|
||||
const probes = run.teamsBasePathsToProbe.map((probe) => ({
|
||||
...probe,
|
||||
configPath: path.join(probe.basePath, run.teamName, 'config.json'),
|
||||
configPath: this.resolveSafeTeamStoragePath(probe.basePath, run.teamName, 'config.json'),
|
||||
}));
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
|
|
@ -35549,7 +35670,7 @@ export class TeamProvisioningService {
|
|||
if (run.expectedMembers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const inboxDir = path.join(getTeamsBasePath(), run.teamName, 'inboxes');
|
||||
const inboxDir = this.resolveSafeTeamStoragePath(getTeamsBasePath(), run.teamName, 'inboxes');
|
||||
const deadline = Date.now() + VERIFY_TIMEOUT_MS;
|
||||
let missing = new Set(run.expectedMembers);
|
||||
|
||||
|
|
@ -35559,7 +35680,12 @@ export class TeamProvisioningService {
|
|||
}
|
||||
const nextMissing = new Set<string>();
|
||||
for (const member of missing) {
|
||||
const inboxPath = path.join(inboxDir, `${member}.json`);
|
||||
const safeMemberName = this.normalizeSafeInboxBaseName(member);
|
||||
if (!safeMemberName) {
|
||||
nextMissing.add(member);
|
||||
continue;
|
||||
}
|
||||
const inboxPath = this.resolveSafeInboxFilePath(inboxDir, `${safeMemberName}.json`);
|
||||
if (!(await this.pathExists(inboxPath))) {
|
||||
nextMissing.add(member);
|
||||
}
|
||||
|
|
@ -35960,7 +36086,7 @@ export class TeamProvisioningService {
|
|||
* is interrupted. On failure, restorePrelaunchConfig() reverts to the backup.
|
||||
*/
|
||||
private async updateConfigProjectPath(teamName: string, cwd: string): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'config.json');
|
||||
try {
|
||||
const raw = await tryReadRegularFileUtf8(configPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
|
|
@ -36142,7 +36268,7 @@ export class TeamProvisioningService {
|
|||
): Promise<void> {
|
||||
const MAX_SESSION_HISTORY = 5000;
|
||||
const MAX_PROJECT_PATH_HISTORY = 500;
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'config.json');
|
||||
try {
|
||||
const raw = await tryReadRegularFileUtf8(configPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
|
|
@ -36229,7 +36355,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
private async cleanupCliAutoSuffixedMembers(teamName: string): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'config.json');
|
||||
|
||||
const removedFromConfig: string[] = [];
|
||||
try {
|
||||
|
|
@ -36370,7 +36496,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
private async assertConfigLeadOnlyForLaunch(teamName: string): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'config.json');
|
||||
const raw = await tryReadRegularFileUtf8(configPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
maxBytes: TEAM_CONFIG_MAX_BYTES,
|
||||
|
|
@ -36418,7 +36544,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
private async normalizeTeamConfigForLaunch(teamName: string, configRaw: string): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'config.json');
|
||||
const backupPath = `${configPath}.prelaunch.bak`;
|
||||
|
||||
let parsed: unknown;
|
||||
|
|
@ -36546,7 +36672,7 @@ export class TeamProvisioningService {
|
|||
* Restore config.json from prelaunch backup if launch fails after normalization.
|
||||
*/
|
||||
private async restorePrelaunchConfig(teamName: string): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'config.json');
|
||||
const backupPath = `${configPath}.prelaunch.bak`;
|
||||
try {
|
||||
const backupRaw = await tryReadRegularFileUtf8(backupPath, {
|
||||
|
|
@ -36568,7 +36694,7 @@ export class TeamProvisioningService {
|
|||
* Remove the prelaunch backup file after a successful launch.
|
||||
*/
|
||||
async cleanupPrelaunchBackup(teamName: string): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'config.json');
|
||||
const backupPath = `${configPath}.prelaunch.bak`;
|
||||
try {
|
||||
await fs.promises.unlink(backupPath);
|
||||
|
|
@ -36583,7 +36709,7 @@ export class TeamProvisioningService {
|
|||
): Promise<void> {
|
||||
if (baseNames.size === 0) return;
|
||||
|
||||
const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes');
|
||||
const inboxDir = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'inboxes');
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(inboxDir);
|
||||
|
|
@ -36591,23 +36717,29 @@ export class TeamProvisioningService {
|
|||
return;
|
||||
}
|
||||
|
||||
const existing = new Set(entries.filter((e) => e.endsWith('.json') && !e.startsWith('.')));
|
||||
const existing = new Set(
|
||||
entries.filter((entry) => this.isSafeInboxJsonFileName(entry) && !entry.startsWith('.'))
|
||||
);
|
||||
|
||||
for (const baseName of baseNames) {
|
||||
for (const rawBaseName of baseNames) {
|
||||
const baseName = this.normalizeSafeInboxBaseName(rawBaseName);
|
||||
if (!baseName) {
|
||||
continue;
|
||||
}
|
||||
const canonicalFile = `${baseName}.json`;
|
||||
if (!existing.has(canonicalFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const duplicates = Array.from(existing)
|
||||
.filter((file) => file.startsWith(`${baseName}-`) && file.endsWith('.json'))
|
||||
.filter((file) => /-\d+\.json$/.test(file));
|
||||
const duplicates = Array.from(existing).filter((file) =>
|
||||
this.isSafeAutoSuffixedInboxDuplicate(file, baseName)
|
||||
);
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalPath = path.join(inboxDir, canonicalFile);
|
||||
const canonicalPath = this.resolveSafeInboxFilePath(inboxDir, canonicalFile);
|
||||
let canonicalRaw: string;
|
||||
try {
|
||||
const raw = await tryReadRegularFileUtf8(canonicalPath, {
|
||||
|
|
@ -36633,7 +36765,7 @@ export class TeamProvisioningService {
|
|||
|
||||
const merged = [...canonicalList];
|
||||
for (const dupFile of duplicates) {
|
||||
const dupPath = path.join(inboxDir, dupFile);
|
||||
const dupPath = this.resolveSafeInboxFilePath(inboxDir, dupFile);
|
||||
let dupRaw: string;
|
||||
try {
|
||||
const raw = await tryReadRegularFileUtf8(dupPath, {
|
||||
|
|
@ -36700,7 +36832,7 @@ export class TeamProvisioningService {
|
|||
|
||||
for (const dupFile of duplicates) {
|
||||
try {
|
||||
await fs.promises.unlink(path.join(inboxDir, dupFile));
|
||||
await fs.promises.unlink(this.resolveSafeInboxFilePath(inboxDir, dupFile));
|
||||
existing.delete(dupFile);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
|
|
@ -37696,7 +37828,7 @@ export class TeamProvisioningService {
|
|||
);
|
||||
const teamName = 'mcp-validation-team';
|
||||
const memberName = 'mcp-validation-member';
|
||||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||
const teamDir = resolveValidatedTeamStoragePath(path.join(claudeDir, 'teams'), teamName);
|
||||
|
||||
await fs.promises.mkdir(teamDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import {
|
||||
type ChildProcess,
|
||||
exec,
|
||||
execFile,
|
||||
type ExecFileOptions,
|
||||
type ExecOptions,
|
||||
spawn,
|
||||
type SpawnOptions,
|
||||
spawnSync,
|
||||
|
|
@ -80,65 +78,14 @@ function execFileAsync(
|
|||
}
|
||||
|
||||
/**
|
||||
* Promise wrapper for exec. Used exclusively as a Windows shell fallback
|
||||
* when execFile fails with EINVAL on non-ASCII binary paths. The command
|
||||
* string is built from a known binary path + args, NOT from user input.
|
||||
* cmd.exe fallback implemented through execFile so Node does not invoke an
|
||||
* additional shell around the guarded command string.
|
||||
*/
|
||||
function execShellAsync(
|
||||
cmd: string,
|
||||
options: ExecOptions = {}
|
||||
options: ExecFileOptions = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { timeout, killSignal, ...execOptions } = options;
|
||||
const timeoutMs = typeof timeout === 'number' && timeout > 0 ? timeout : 0;
|
||||
const timeoutSignal = normalizeKillSignal(killSignal);
|
||||
let child: ChildProcess | null = null;
|
||||
let settled = false;
|
||||
let stdoutText = '';
|
||||
let stderrText = '';
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
// eslint-disable-next-line sonarjs/os-command, security/detect-child-process -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
|
||||
child = exec(cmd, execOptions, (err, stdout, stderr) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
|
||||
if (err)
|
||||
reject(
|
||||
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error')
|
||||
);
|
||||
else resolve({ stdout: String(stdout), stderr: String(stderr) });
|
||||
});
|
||||
if (!settled) {
|
||||
trackCliProcess(child);
|
||||
if (timeoutMs > 0) {
|
||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
stdoutText += chunk.toString();
|
||||
});
|
||||
child.stderr?.on('data', (chunk: Buffer | string) => {
|
||||
stderrText += chunk.toString();
|
||||
});
|
||||
timeoutHandle = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
timeoutHandle = cleanupTimedCliProcess(child, timeoutHandle);
|
||||
killProcessTree(child, timeoutSignal);
|
||||
const error = new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`);
|
||||
Object.assign(error, {
|
||||
killed: true,
|
||||
signal: timeoutSignal,
|
||||
stdout: stdoutText,
|
||||
stderr: stderrText,
|
||||
});
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
timeoutHandle.unref?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
return execFileAsync(getWindowsCmdPath(), ['/d', '/s', '/c', cmd], options);
|
||||
}
|
||||
|
||||
function cleanupTimedCliProcess(
|
||||
|
|
@ -300,6 +247,48 @@ function quoteArg(arg: string): string {
|
|||
return quoteWindowsCmdArg(arg);
|
||||
}
|
||||
|
||||
const WINDOWS_SHELL_UNSAFE_META_CHAR_RE = /[&|<>^]/u;
|
||||
|
||||
function containsWindowsShellUnsafeControlChar(part: string): boolean {
|
||||
for (let index = 0; index < part.length; index += 1) {
|
||||
const code = part.charCodeAt(index);
|
||||
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function assertSafeWindowsShellFallbackPart(part: string): void {
|
||||
if (containsWindowsShellUnsafeControlChar(part)) {
|
||||
throw new Error('Unsafe Windows shell fallback argument: control characters are not allowed');
|
||||
}
|
||||
if (WINDOWS_SHELL_UNSAFE_META_CHAR_RE.test(part)) {
|
||||
throw new Error('Unsafe Windows shell fallback argument: shell metacharacters are not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
function buildWindowsShellFallbackCommand(parts: string[]): string {
|
||||
for (const part of parts) {
|
||||
assertSafeWindowsShellFallbackPart(part);
|
||||
}
|
||||
return parts.map(quoteArg).join(' ');
|
||||
}
|
||||
|
||||
function getWindowsCmdPath(): string {
|
||||
return path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe');
|
||||
}
|
||||
|
||||
function spawnWindowsShellFallback(
|
||||
cmd: string,
|
||||
options: ReturnType<typeof withCliProcessDefaults<SpawnOptions>>
|
||||
): ReturnType<typeof spawn> {
|
||||
return spawn(getWindowsCmdPath(), ['/d', '/s', '/c', cmd], {
|
||||
...options,
|
||||
shell: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Env vars injected into every spawned Claude CLI process. */
|
||||
const CLI_ENV_DEFAULTS: Record<string, string> = {
|
||||
CLAUDE_HOOK_JUDGE_MODE: 'true',
|
||||
|
|
@ -408,8 +397,8 @@ export async function execCli(
|
|||
}
|
||||
|
||||
// shell fallback (Windows only; others shouldn't reach here)
|
||||
const cmd = [target, ...args].map(quoteArg).join(' ');
|
||||
const shellResult = await execShellAsync(cmd, opts as unknown as ExecOptions);
|
||||
const cmd = buildWindowsShellFallbackCommand([target, ...args]);
|
||||
const shellResult = await execShellAsync(cmd, opts);
|
||||
return { stdout: String(shellResult.stdout), stderr: String(shellResult.stderr) };
|
||||
}
|
||||
|
||||
|
|
@ -435,9 +424,8 @@ export function spawnCli(
|
|||
}
|
||||
|
||||
if (process.platform === 'win32' && needsShell(binaryPath)) {
|
||||
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
|
||||
// eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
|
||||
return trackCliProcess(spawn(cmd, { ...opts, shell: true }));
|
||||
const cmd = buildWindowsShellFallbackCommand([binaryPath, ...args]);
|
||||
return trackCliProcess(spawnWindowsShellFallback(cmd, opts));
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -446,9 +434,8 @@ export function spawnCli(
|
|||
const code =
|
||||
err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined;
|
||||
if (process.platform === 'win32' && code === 'EINVAL') {
|
||||
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
|
||||
// eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
|
||||
return trackCliProcess(spawn(cmd, { ...opts, shell: true }));
|
||||
const cmd = buildWindowsShellFallbackCommand([binaryPath, ...args]);
|
||||
return trackCliProcess(spawnWindowsShellFallback(cmd, opts));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -606,6 +606,13 @@ function createMemberSpawnStatusEntry(
|
|||
}
|
||||
|
||||
type TeamProvisioningServicePrivateHarness = {
|
||||
resolveProjectClaudeSettingsPath: (projectCwd: string) => Promise<string>;
|
||||
resolveSafeTeamStoragePath: (
|
||||
basePath: string,
|
||||
teamName: string,
|
||||
...segments: string[]
|
||||
) => string;
|
||||
mergeAndRemoveDuplicateInboxes: (teamName: string, baseNames: Set<string>) => Promise<void>;
|
||||
getLiveTeamAgentRuntimeMetadata: (
|
||||
teamName: string
|
||||
) => Promise<Map<string, Record<string, unknown>>>;
|
||||
|
|
@ -17946,6 +17953,112 @@ describe('TeamProvisioningService', () => {
|
|||
expect(settings.permissions?.allow).toEqual(['mcp__agent-teams__team_stop']);
|
||||
});
|
||||
|
||||
it('resolves project Claude local settings only inside an absolute project cwd', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const settingsPath = await privateHarness(svc).resolveProjectClaudeSettingsPath(tempClaudeRoot);
|
||||
|
||||
expect(settingsPath).toBe(
|
||||
path.join(fs.realpathSync(tempClaudeRoot), '.claude', 'settings.local.json')
|
||||
);
|
||||
fs.writeFileSync(settingsPath, '{}', 'utf8');
|
||||
expect(fs.existsSync(path.join(tempClaudeRoot, '.claude', 'settings.local.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unsafe project cwd values for local settings writes', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const harness = privateHarness(svc);
|
||||
const filePath = path.join(tempClaudeRoot, 'not-a-directory');
|
||||
fs.writeFileSync(filePath, 'x', 'utf8');
|
||||
|
||||
await expect(harness.resolveProjectClaudeSettingsPath('relative/project')).rejects.toThrow(
|
||||
'absolute path'
|
||||
);
|
||||
await expect(
|
||||
harness.resolveProjectClaudeSettingsPath(path.join(tempClaudeRoot, 'missing'))
|
||||
).rejects.toThrow('does not exist');
|
||||
await expect(harness.resolveProjectClaudeSettingsPath(filePath)).rejects.toThrow(
|
||||
'not a directory'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects .claude symlink escapes for local settings writes', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const harness = privateHarness(svc);
|
||||
const projectDir = path.join(tempClaudeRoot, 'project-with-symlink');
|
||||
const outsideDir = path.join(tempClaudeRoot, 'outside-claude');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
fs.symlinkSync(
|
||||
outsideDir,
|
||||
path.join(projectDir, '.claude'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
);
|
||||
|
||||
await expect(harness.resolveProjectClaudeSettingsPath(projectDir)).rejects.toThrow(
|
||||
'outside project cwd'
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves team storage paths only for validated team names', () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const harness = privateHarness(svc);
|
||||
|
||||
expect(harness.resolveSafeTeamStoragePath(tempTeamsBase, 'safe-team', 'config.json')).toBe(
|
||||
path.join(tempTeamsBase, 'safe-team', 'config.json')
|
||||
);
|
||||
expect(() => harness.resolveSafeTeamStoragePath(tempTeamsBase, '../bad')).toThrow(
|
||||
/teamName contains invalid characters/i
|
||||
);
|
||||
expect(() => harness.resolveSafeTeamStoragePath(tempTeamsBase, 'bad/name')).toThrow(
|
||||
/teamName contains invalid characters/i
|
||||
);
|
||||
expect(() => harness.resolveSafeTeamStoragePath(tempTeamsBase, 'bad\\name')).toThrow(
|
||||
/teamName contains invalid characters/i
|
||||
);
|
||||
});
|
||||
|
||||
it('cleans only expected auto-suffixed inbox duplicates', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const harness = privateHarness(svc);
|
||||
const teamName = 'safe-inbox-cleanup';
|
||||
const inboxDir = path.join(tempTeamsBase, teamName, 'inboxes');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(inboxDir, 'alice.json'),
|
||||
JSON.stringify([{ messageId: 'm1', timestamp: '2026-01-01T00:00:00.000Z' }]),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(inboxDir, 'alice-2.json'),
|
||||
JSON.stringify([{ messageId: 'm2', timestamp: '2026-01-02T00:00:00.000Z' }]),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(path.join(inboxDir, 'alice-x.json'), '[]', 'utf8');
|
||||
fs.writeFileSync(path.join(inboxDir, 'bob-2.json'), '[]', 'utf8');
|
||||
|
||||
await harness.mergeAndRemoveDuplicateInboxes(teamName, new Set(['alice', '../escape']));
|
||||
|
||||
const merged = JSON.parse(fs.readFileSync(path.join(inboxDir, 'alice.json'), 'utf8')) as {
|
||||
messageId?: string;
|
||||
}[];
|
||||
expect(merged.map((message) => message.messageId)).toEqual(['m2', 'm1']);
|
||||
expect(fs.existsSync(path.join(inboxDir, 'alice-2.json'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(inboxDir, 'alice-x.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(inboxDir, 'bob-2.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unsafe team names before inbox cleanup paths are built', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const harness = privateHarness(svc);
|
||||
|
||||
await expect(
|
||||
harness.mergeAndRemoveDuplicateInboxes('../bad', new Set(['alice']))
|
||||
).rejects.toThrow(/teamName contains invalid characters/i);
|
||||
await expect(
|
||||
harness.mergeAndRemoveDuplicateInboxes('bad\\name', new Set(['alice']))
|
||||
).rejects.toThrow(/teamName contains invalid characters/i);
|
||||
});
|
||||
|
||||
it('builds teammate AskUserQuestion permission responses with answers', () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const toolInput = {
|
||||
|
|
|
|||
|
|
@ -199,13 +199,18 @@ describe('cli child process helpers', () => {
|
|||
env: { FOO: 'bar' },
|
||||
});
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
const secondArg0 = spawnMock.mock.calls[1][0] as string;
|
||||
expect(secondArg0).toMatch(/claude\.exe/);
|
||||
expect(spawnMock.mock.calls[1][1]).toMatchObject({ shell: true, env: { FOO: 'bar' } });
|
||||
expect(spawnMock.mock.calls[1][0]).toMatch(/cmd\.exe$/i);
|
||||
expect(spawnMock.mock.calls[1][1]).toEqual([
|
||||
'/d',
|
||||
'/s',
|
||||
'/c',
|
||||
expect.stringMatching(/claude\.exe/),
|
||||
]);
|
||||
expect(spawnMock.mock.calls[1][2]).toMatchObject({ shell: false, env: { FOO: 'bar' } });
|
||||
expect(result).toBe(fake);
|
||||
});
|
||||
|
||||
it('uses shell directly for Windows cmd launchers', () => {
|
||||
it('uses cmd.exe directly for Windows cmd launcher shell fallback', () => {
|
||||
setPlatform('win32');
|
||||
const fake = createMockProcess<SpawnCliChild>();
|
||||
const spawnMock = child.spawn as unknown as Mock;
|
||||
|
|
@ -213,8 +218,14 @@ describe('cli child process helpers', () => {
|
|||
|
||||
const result = spawnCli('C:\\runtime\\cli-dev.cmd', ['--version']);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock.mock.calls[0][0]).toContain('cli-dev.cmd');
|
||||
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true });
|
||||
expect(spawnMock.mock.calls[0][0]).toMatch(/cmd\.exe$/i);
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual([
|
||||
'/d',
|
||||
'/s',
|
||||
'/c',
|
||||
expect.stringContaining('cli-dev.cmd'),
|
||||
]);
|
||||
expect(spawnMock.mock.calls[0][2]).toMatchObject({ shell: false });
|
||||
expect(result).toBe(fake);
|
||||
});
|
||||
|
||||
|
|
@ -282,14 +293,57 @@ describe('cli child process helpers', () => {
|
|||
const result = spawnCli('C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd', ['a', 'b'], {
|
||||
env: { FOO: 'bar' },
|
||||
});
|
||||
// Non-ASCII detected upfront — single spawn call with shell: true
|
||||
// Non-ASCII detected upfront, so launch through cmd.exe fallback once.
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
const shellCmd = spawnMock.mock.calls[0][0] as string;
|
||||
expect(spawnMock.mock.calls[0][0]).toMatch(/cmd\.exe$/i);
|
||||
const shellCmd = spawnMock.mock.calls[0][1][3] as string;
|
||||
expect(shellCmd).toMatch(/claude\.cmd/);
|
||||
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true, env: { FOO: 'bar' } });
|
||||
expect(spawnMock.mock.calls[0][2]).toMatchObject({ shell: false, env: { FOO: 'bar' } });
|
||||
expect(result).toBe(fake);
|
||||
});
|
||||
|
||||
it('rejects control characters only when Windows shell fallback is needed', () => {
|
||||
setPlatform('win32');
|
||||
const spawnMock = child.spawn as unknown as Mock;
|
||||
spawnMock.mockReturnValue(createMockProcess<SpawnCliChild>());
|
||||
|
||||
for (const unsafeArg of [
|
||||
'safe\0bad',
|
||||
'safe\rbad',
|
||||
'safe\nbad',
|
||||
'safe\u001fbad',
|
||||
'safe\u0085bad',
|
||||
]) {
|
||||
expect(() => spawnCli('C:\\Users\\Алексей\\bin\\claude.cmd', [unsafeArg])).toThrow(
|
||||
'control characters are not allowed'
|
||||
);
|
||||
}
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
|
||||
spawnCli('C:\\bin\\claude.exe', ['safe\nargv']);
|
||||
expect(spawnMock.mock.calls[0][0]).toBe('C:\\bin\\claude.exe');
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual(['safe\nargv']);
|
||||
expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell');
|
||||
});
|
||||
|
||||
it('rejects shell metacharacters only when Windows shell fallback is needed', () => {
|
||||
setPlatform('win32');
|
||||
const spawnMock = child.spawn as unknown as Mock;
|
||||
spawnMock.mockReturnValue(createMockProcess<SpawnCliChild>());
|
||||
|
||||
for (const unsafeArg of ['safe&bad', 'safe|bad', 'safe<bad', 'safe>bad', 'safe^bad']) {
|
||||
expect(() => spawnCli('C:\\Users\\Алексей\\bin\\claude.cmd', [unsafeArg])).toThrow(
|
||||
'shell metacharacters are not allowed'
|
||||
);
|
||||
}
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
|
||||
spawnCli('C:\\bin\\claude.exe', ['safe&argv']);
|
||||
expect(spawnMock.mock.calls[0][0]).toBe('C:\\bin\\claude.exe');
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual(['safe&argv']);
|
||||
expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell');
|
||||
});
|
||||
|
||||
it('does not use shell when not on windows', () => {
|
||||
setPlatform('linux');
|
||||
const fake = createMockProcess<SpawnCliChild>();
|
||||
|
|
@ -387,18 +441,25 @@ describe('cli child process helpers', () => {
|
|||
expect(execFileMock.mock.calls[1][2]).toMatchObject({ windowsHide: false });
|
||||
});
|
||||
|
||||
it('skips straight to shell for Windows cmd launchers', async () => {
|
||||
it('skips straight to cmd.exe fallback for Windows cmd launchers', async () => {
|
||||
setPlatform('win32');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
const execMock = child.exec as unknown as Mock;
|
||||
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
cb(null, '0.0.8', '');
|
||||
return createMockProcess<ExecChild>();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const result = await execCli('C:\\runtime\\cli-dev.cmd', ['--version']);
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
expect(execMock).toHaveBeenCalled();
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/cmd\.exe$/i),
|
||||
['/d', '/s', '/c', expect.stringContaining('cli-dev.cmd')],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
expect(result.stdout).toBe('0.0.8');
|
||||
});
|
||||
|
||||
|
|
@ -429,19 +490,22 @@ describe('cli child process helpers', () => {
|
|||
setPlatform('win32');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
const execMock = child.exec as unknown as Mock;
|
||||
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
cb(null, 'ok', '');
|
||||
return createMockProcess<ExecChild>();
|
||||
});
|
||||
}
|
||||
);
|
||||
const { dir, launcher } = createGeneratedBunLauncher();
|
||||
try {
|
||||
const result = await execCli(launcher, ['runtime', 'opencode-command'], {
|
||||
preferShellForWindowsBatch: true,
|
||||
});
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
expect(execMock).toHaveBeenCalledTimes(1);
|
||||
expect(execMock.mock.calls[0][0]).toContain('runtime');
|
||||
expect(execMock.mock.calls[0][0]).toContain('opencode-command');
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(execFileMock.mock.calls[0][0]).toMatch(/cmd\.exe$/i);
|
||||
expect(execFileMock.mock.calls[0][1][3]).toContain('runtime');
|
||||
expect(execFileMock.mock.calls[0][1][3]).toContain('opencode-command');
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
expect(result.stdout).toBe('ok');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
|
|
@ -499,30 +563,38 @@ describe('cli child process helpers', () => {
|
|||
setPlatform('win32');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
const execMock = child.exec as unknown as Mock;
|
||||
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
cb(null, '1.2.3', '');
|
||||
return createMockProcess<ExecChild>();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const result = await execCli('C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd', [
|
||||
'--version',
|
||||
]);
|
||||
// non-ASCII path detected upfront — execFile should NOT be called
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
expect(execMock).toHaveBeenCalled();
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/cmd\.exe$/i),
|
||||
['/d', '/s', '/c', expect.stringContaining('claude.cmd')],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
expect(result.stdout).toBe('1.2.3');
|
||||
});
|
||||
|
||||
it('escapes percent signs and quotes for cmd.exe in shell fallback', async () => {
|
||||
setPlatform('win32');
|
||||
const execMock = child.exec as unknown as Mock;
|
||||
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
cb(null, 'ok', '');
|
||||
return createMockProcess<ExecChild>();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
await execCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--model', 'test%PATH%"arg']);
|
||||
const shellCmd = execMock.mock.calls[0][0] as string;
|
||||
const shellCmd = execFileMock.mock.calls[0][1][3] as string;
|
||||
// Keep % outside quoted chunks so cmd.exe does not expand it as an env var.
|
||||
expect(shellCmd).toContain('^%"PATH"^%');
|
||||
expect(shellCmd).not.toContain('%PATH%');
|
||||
|
|
@ -534,11 +606,13 @@ describe('cli child process helpers', () => {
|
|||
|
||||
it('keeps inline settings JSON as one argv-safe argument for Windows cmd launchers', async () => {
|
||||
setPlatform('win32');
|
||||
const execMock = child.exec as unknown as Mock;
|
||||
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
cb(null, 'ok', '');
|
||||
return createMockProcess<ExecChild>();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
await execCli('C:\\runtime\\cli-dev.cmd', [
|
||||
'--settings',
|
||||
|
|
@ -549,25 +623,25 @@ describe('cli child process helpers', () => {
|
|||
'--provider',
|
||||
'codex',
|
||||
]);
|
||||
const shellCmd = execMock.mock.calls[0][0] as string;
|
||||
const shellCmd = execFileMock.mock.calls[0][1][3] as string;
|
||||
expect(shellCmd).toContain('"{\\"codex\\":{\\"forced_login_method\\":\\"chatgpt\\"}}"');
|
||||
expect(shellCmd).not.toContain('{""codex"":');
|
||||
});
|
||||
|
||||
it('shell: true cannot be overridden by caller options', () => {
|
||||
it('does not pass caller shell options into cmd.exe fallback', () => {
|
||||
setPlatform('win32');
|
||||
const spawnMock = child.spawn as unknown as Mock;
|
||||
spawnMock.mockReturnValue(createMockProcess<SpawnCliChild>());
|
||||
|
||||
spawnCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--version'], { shell: false });
|
||||
// shell: true must win over caller's shell: false
|
||||
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true });
|
||||
spawnCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--version'], { shell: true });
|
||||
expect(spawnMock.mock.calls[0][0]).toMatch(/cmd\.exe$/i);
|
||||
expect(spawnMock.mock.calls[0][2]).toMatchObject({ shell: false });
|
||||
});
|
||||
|
||||
it('falls back to shell when execFile throws EINVAL on windows', async () => {
|
||||
setPlatform('win32');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
execFileMock.mockImplementation(
|
||||
execFileMock.mockImplementationOnce(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
const err = new Error('spawn EINVAL') as Error & { code?: string };
|
||||
err.code = 'EINVAL';
|
||||
|
|
@ -575,19 +649,48 @@ describe('cli child process helpers', () => {
|
|||
return createMockProcess<ExecChild>();
|
||||
}
|
||||
);
|
||||
const execMock = child.exec as unknown as Mock;
|
||||
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
|
||||
cb(null, '2.3.4', '');
|
||||
return createMockProcess<ExecChild>();
|
||||
});
|
||||
execFileMock.mockImplementationOnce(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
cb(null, '2.3.4', '');
|
||||
return createMockProcess<ExecChild>();
|
||||
}
|
||||
);
|
||||
|
||||
// ASCII path — goes through execFile first, gets EINVAL, falls back to shell
|
||||
const result = await execCli('C:\\bin\\claude.exe', ['--version']);
|
||||
expect(execFileMock).toHaveBeenCalled();
|
||||
expect(execMock).toHaveBeenCalled();
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2);
|
||||
expect(execFileMock.mock.calls[1][0]).toMatch(/cmd\.exe$/i);
|
||||
expect(result.stdout).toBe('2.3.4');
|
||||
});
|
||||
|
||||
it('rejects control characters when execCli needs Windows shell fallback', async () => {
|
||||
setPlatform('win32');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
execFileMock.mockImplementationOnce(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
const err = new Error('spawn EINVAL') as Error & { code?: string };
|
||||
err.code = 'EINVAL';
|
||||
cb(err, '', '');
|
||||
return createMockProcess<ExecChild>();
|
||||
}
|
||||
);
|
||||
|
||||
await expect(execCli('C:\\bin\\claude.exe', ['safe\rbad'])).rejects.toThrow(
|
||||
'control characters are not allowed'
|
||||
);
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects shell metacharacters when execCli needs Windows shell fallback', async () => {
|
||||
setPlatform('win32');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
|
||||
await expect(
|
||||
execCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['safe&bad'])
|
||||
).rejects.toThrow('shell metacharacters are not allowed');
|
||||
expect(execFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves stdout and stderr on execFile failures', async () => {
|
||||
setPlatform('linux');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
|
|
|
|||
Loading…
Reference in a new issue