From 6a94aec5da66f5afaeb6eeb57bb04c9793147ba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 17:44:25 +0000 Subject: [PATCH] Secure IPC with per-group namespaces to prevent privilege escalation Each container now gets its own IPC directory (/data/ipc/{groupFolder}/) instead of a shared global directory. Identity is determined by which directory a request came from, not by self-reported data in IPC files. Authorization enforced: - send_message: only to chatJids belonging to the source group - schedule_task: only for the source group (main can target any) - pause/resume/cancel_task: only for tasks owned by source group https://claude.ai/code/session_018nmxNEbtgJH7cKDyBSQGAw --- src/container-runner.ts | 47 +++++++---- src/index.ts | 171 +++++++++++++++++++++++++--------------- src/task-scheduler.ts | 6 +- 3 files changed, 141 insertions(+), 83 deletions(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 3c6c783..1254c26 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -101,11 +101,13 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount }); } - const ipcDir = path.join(DATA_DIR, 'ipc'); - fs.mkdirSync(path.join(ipcDir, 'messages'), { recursive: true }); - fs.mkdirSync(path.join(ipcDir, 'tasks'), { recursive: true }); + // Per-group IPC namespace: each group gets its own IPC directory + // This prevents cross-group privilege escalation via IPC + const groupIpcDir = path.join(DATA_DIR, 'ipc', group.folder); + fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); + fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); mounts.push({ - hostPath: ipcDir, + hostPath: groupIpcDir, containerPath: '/workspace/ipc', readonly: false }); @@ -337,17 +339,28 @@ export async function runContainerAgent( }); } -export function writeTasksSnapshot(tasks: Array<{ - id: string; - groupFolder: string; - prompt: string; - schedule_type: string; - schedule_value: string; - status: string; - next_run: string | null; -}>): void { - const ipcDir = path.join(DATA_DIR, 'ipc'); - fs.mkdirSync(ipcDir, { recursive: true }); - const tasksFile = path.join(ipcDir, 'current_tasks.json'); - fs.writeFileSync(tasksFile, JSON.stringify(tasks, null, 2)); +export function writeTasksSnapshot( + groupFolder: string, + isMain: boolean, + tasks: Array<{ + id: string; + groupFolder: string; + prompt: string; + schedule_type: string; + schedule_value: string; + status: string; + next_run: string | null; + }> +): void { + // Write filtered tasks to the group's IPC directory + const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder); + fs.mkdirSync(groupIpcDir, { recursive: true }); + + // Main sees all tasks, others only see their own + const filteredTasks = isMain + ? tasks + : tasks.filter(t => t.groupFolder === groupFolder); + + const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); + fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); } diff --git a/src/index.ts b/src/index.ts index 8167aef..298848d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,9 +97,9 @@ async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string) const isMain = group.folder === MAIN_GROUP_FOLDER; const sessionId = sessions[group.folder]; - // Update tasks snapshot for container to read + // Update tasks snapshot for container to read (filtered by group) const tasks = getAllTasks(); - writeTasksSnapshot(tasks.map(t => ({ + writeTasksSnapshot(group.folder, isMain, tasks.map(t => ({ id: t.id, groupFolder: t.group_folder, prompt: t.prompt, @@ -145,72 +145,103 @@ async function sendMessage(jid: string, text: string): Promise { } function startIpcWatcher(): void { - const messagesDir = path.join(DATA_DIR, 'ipc', 'messages'); - const tasksDir = path.join(DATA_DIR, 'ipc', 'tasks'); - - fs.mkdirSync(messagesDir, { recursive: true }); - fs.mkdirSync(tasksDir, { recursive: true }); + const ipcBaseDir = path.join(DATA_DIR, 'ipc'); + fs.mkdirSync(ipcBaseDir, { recursive: true }); const processIpcFiles = async () => { + // Scan all group IPC directories (identity determined by directory) + let groupFolders: string[]; try { - const messageFiles = fs.readdirSync(messagesDir).filter(f => f.endsWith('.json')); - for (const file of messageFiles) { - const filePath = path.join(messagesDir, file); - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - if (data.type === 'message' && data.chatJid && data.text) { - await sendMessage(data.chatJid, `${ASSISTANT_NAME}: ${data.text}`); - logger.info({ chatJid: data.chatJid }, 'IPC message sent'); - } - fs.unlinkSync(filePath); - } catch (err) { - logger.error({ file, err }, 'Error processing IPC message'); - // Move to error directory instead of deleting - const errorDir = path.join(DATA_DIR, 'ipc', 'errors'); - fs.mkdirSync(errorDir, { recursive: true }); - fs.renameSync(filePath, path.join(errorDir, file)); - } - } + groupFolders = fs.readdirSync(ipcBaseDir).filter(f => { + const stat = fs.statSync(path.join(ipcBaseDir, f)); + return stat.isDirectory() && f !== 'errors'; + }); } catch (err) { - logger.error({ err }, 'Error reading IPC messages directory'); + logger.error({ err }, 'Error reading IPC base directory'); + setTimeout(processIpcFiles, IPC_POLL_INTERVAL); + return; } - try { - const taskFiles = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json')); - for (const file of taskFiles) { - const filePath = path.join(tasksDir, file); - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - await processTaskIpc(data); - fs.unlinkSync(filePath); - } catch (err) { - logger.error({ file, err }, 'Error processing IPC task'); - const errorDir = path.join(DATA_DIR, 'ipc', 'errors'); - fs.mkdirSync(errorDir, { recursive: true }); - fs.renameSync(filePath, path.join(errorDir, file)); + for (const sourceGroup of groupFolders) { + const isMain = sourceGroup === MAIN_GROUP_FOLDER; + const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages'); + const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks'); + + // Process messages from this group's IPC directory + try { + if (fs.existsSync(messagesDir)) { + const messageFiles = fs.readdirSync(messagesDir).filter(f => f.endsWith('.json')); + for (const file of messageFiles) { + const filePath = path.join(messagesDir, file); + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (data.type === 'message' && data.chatJid && data.text) { + // Authorization: verify this group can send to this chatJid + const targetGroup = registeredGroups[data.chatJid]; + if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { + await sendMessage(data.chatJid, `${ASSISTANT_NAME}: ${data.text}`); + logger.info({ chatJid: data.chatJid, sourceGroup }, 'IPC message sent'); + } else { + logger.warn({ chatJid: data.chatJid, sourceGroup }, 'Unauthorized IPC message attempt blocked'); + } + } + fs.unlinkSync(filePath); + } catch (err) { + logger.error({ file, sourceGroup, err }, 'Error processing IPC message'); + const errorDir = path.join(ipcBaseDir, 'errors'); + fs.mkdirSync(errorDir, { recursive: true }); + fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`)); + } + } } + } catch (err) { + logger.error({ err, sourceGroup }, 'Error reading IPC messages directory'); + } + + // Process tasks from this group's IPC directory + try { + if (fs.existsSync(tasksDir)) { + const taskFiles = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json')); + for (const file of taskFiles) { + const filePath = path.join(tasksDir, file); + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + // Pass source group identity to processTaskIpc for authorization + await processTaskIpc(data, sourceGroup, isMain); + fs.unlinkSync(filePath); + } catch (err) { + logger.error({ file, sourceGroup, err }, 'Error processing IPC task'); + const errorDir = path.join(ipcBaseDir, 'errors'); + fs.mkdirSync(errorDir, { recursive: true }); + fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`)); + } + } + } + } catch (err) { + logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory'); } - } catch (err) { - logger.error({ err }, 'Error reading IPC tasks directory'); } setTimeout(processIpcFiles, IPC_POLL_INTERVAL); }; processIpcFiles(); - logger.info('IPC watcher started'); + logger.info('IPC watcher started (per-group namespaces)'); } -async function processTaskIpc(data: { - type: string; - taskId?: string; - prompt?: string; - schedule_type?: string; - schedule_value?: string; - groupFolder?: string; - chatJid?: string; - isMain?: boolean; -}): Promise { +async function processTaskIpc( + data: { + type: string; + taskId?: string; + prompt?: string; + schedule_type?: string; + schedule_value?: string; + groupFolder?: string; + chatJid?: string; + }, + sourceGroup: string, // Verified identity from IPC directory + isMain: boolean // Verified from directory path +): Promise { // Import db functions dynamically to avoid circular deps const { createTask, updateTask, deleteTask, getTaskById: getTask } = await import('./db.js'); const { CronExpressionParser } = await import('cron-parser'); @@ -218,6 +249,20 @@ async function processTaskIpc(data: { switch (data.type) { case 'schedule_task': if (data.prompt && data.schedule_type && data.schedule_value && data.groupFolder && data.chatJid) { + // Authorization: non-main groups can only schedule for themselves + const targetGroup = data.groupFolder; + if (!isMain && targetGroup !== sourceGroup) { + logger.warn({ sourceGroup, targetGroup, chatJid: data.chatJid }, 'Unauthorized schedule_task attempt blocked'); + break; + } + + // Authorization: verify the chatJid belongs to the target group + const chatGroup = registeredGroups[data.chatJid]; + if (!isMain && (!chatGroup || chatGroup.folder !== targetGroup)) { + logger.warn({ sourceGroup, targetGroup, chatJid: data.chatJid }, 'Unauthorized schedule_task chatJid blocked'); + break; + } + const scheduleType = data.schedule_type as 'cron' | 'interval' | 'once'; let nextRun: string | null = null; @@ -234,7 +279,7 @@ async function processTaskIpc(data: { const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createTask({ id: taskId, - group_folder: data.groupFolder, + group_folder: targetGroup, chat_jid: data.chatJid, prompt: data.prompt, schedule_type: scheduleType, @@ -243,18 +288,18 @@ async function processTaskIpc(data: { status: 'active', created_at: new Date().toISOString() }); - logger.info({ taskId, groupFolder: data.groupFolder }, 'Task created via IPC'); + logger.info({ taskId, sourceGroup, targetGroup }, 'Task created via IPC'); } break; case 'pause_task': if (data.taskId) { const task = getTask(data.taskId); - if (task && (data.isMain || task.group_folder === data.groupFolder)) { + if (task && (isMain || task.group_folder === sourceGroup)) { updateTask(data.taskId, { status: 'paused' }); - logger.info({ taskId: data.taskId }, 'Task paused via IPC'); + logger.info({ taskId: data.taskId, sourceGroup }, 'Task paused via IPC'); } else { - logger.warn({ taskId: data.taskId, groupFolder: data.groupFolder }, 'Unauthorized task pause attempt'); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt'); } } break; @@ -262,11 +307,11 @@ async function processTaskIpc(data: { case 'resume_task': if (data.taskId) { const task = getTask(data.taskId); - if (task && (data.isMain || task.group_folder === data.groupFolder)) { + if (task && (isMain || task.group_folder === sourceGroup)) { updateTask(data.taskId, { status: 'active' }); - logger.info({ taskId: data.taskId }, 'Task resumed via IPC'); + logger.info({ taskId: data.taskId, sourceGroup }, 'Task resumed via IPC'); } else { - logger.warn({ taskId: data.taskId, groupFolder: data.groupFolder }, 'Unauthorized task resume attempt'); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt'); } } break; @@ -274,11 +319,11 @@ async function processTaskIpc(data: { case 'cancel_task': if (data.taskId) { const task = getTask(data.taskId); - if (task && (data.isMain || task.group_folder === data.groupFolder)) { + if (task && (isMain || task.group_folder === sourceGroup)) { deleteTask(data.taskId); - logger.info({ taskId: data.taskId }, 'Task cancelled via IPC'); + logger.info({ taskId: data.taskId, sourceGroup }, 'Task cancelled via IPC'); } else { - logger.warn({ taskId: data.taskId, groupFolder: data.groupFolder }, 'Unauthorized task cancel attempt'); + logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt'); } } break; diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 52c9b87..918b51d 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -40,9 +40,10 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis return; } - // Update tasks snapshot for container to read + // Update tasks snapshot for container to read (filtered by group) + const isMain = task.group_folder === MAIN_GROUP_FOLDER; const tasks = getAllTasks(); - writeTasksSnapshot(tasks.map(t => ({ + writeTasksSnapshot(task.group_folder, isMain, tasks.map(t => ({ id: t.id, groupFolder: t.group_folder, prompt: t.prompt, @@ -56,7 +57,6 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis let error: string | null = null; try { - const isMain = task.group_folder === MAIN_GROUP_FOLDER; const output = await runContainerAgent(group, { prompt: task.prompt, groupFolder: task.group_folder,