Root cause
Path & link handling introduced
Add Agent Teams capability
How AI contributed: Direct introductionPublished Apr 28, 2026 in
erlichliu/claude-agent-sdk-master
CWE-22 path traversal / arbitrary file read: POST /api/agent-output accepts JSON {outputFile: string}, normalizes it, requires absolute path, and returns the file's content. Comment explicitly states arbitrary absolute paths are allowed (SDK output_file may live outside cwd). Only guard is isFile() (rejects directories); any readable file on the host (e.g. /etc/passwd, .env, keys) can be exfiltrated remotely. No fix was ever released; HEAD b185aa7ff0d864581257008077b4010fca1747bf (the CVE's 'up to' commit) still contains the identical code.
The POST /api/agent-output route added with the 04-agent-teams tutorial accepts an outputFile string, normalizes it, requires an absolute path and returns that file's contents, with isFile() as the only check and a comment stating that arbitrary absolute paths are allowed. Nothing confines the path to the project directory, so any readable host file can be returned.
Root cause
Add Agent Teams capability
Fix status
No minimum fix commit has been established for this finding.
Fix status unresolved
Case facts
@@ -0,0 +1,791 @@+/**+ * 流式对话 API Route (PromaAgent 重构版)+ *+ * 🎯 核心改进:事件驱动架构+ *+ * ✅ 使用 PromaAgent 类:+ * - 将 SDK 消息转换为标准化的 AgentEvents+ * - 无状态工具匹配(ToolIndex + 直接 ID 匹配)+ * - 清晰的三层架构:SDK → AgentEvents → Frontend+ *+ * 优势:+ * 1. 可测试性:PromaAgent 可独立测试+ * 2. 可复用性:同样的事件可用于 WebSocket、gRPC 等+ * 3. 简洁性:269 行 → ~150 行+ * 4. 可扩展性:轻松添加工具调用、后台任务等功能+ */++import { NextRequest } from 'next/server';+import fs from 'fs/promises';+import path from 'path';+import os from 'os';+import { PromaAgent, type AgentEvent } from '@04-agent-teams/shared/agent';+import type { ChatMessage } from '@04-agent-teams/core';+import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk';+import { getStorage } from '@/lib/storage';+import { addPending, resolvePending } from '@/lib/permission-store';++const TEAMS_DIR = path.join(os.homedir(), '.claude', 'teams');++interface InboxMessage {+ from: string;+ text: string;+ summary?: string;+ timestamp?: string;+ read?: boolean;+}++/**+ * 通过 sessionId 查找对应的 team 名称和 inbox 路径+ */+async function findTeamLeadInboxPath(sessionId: string): Promise<{ teamName: string; inboxPath: string } | null> {+ let teamEntries: string[];+ try {+ teamEntries = await fs.readdir(TEAMS_DIR);+ } catch {+ return null;+ }++ for (const teamName of teamEntries) {+ const configPath = path.join(TEAMS_DIR, teamName, 'config.json');+ try {+ const raw = await fs.readFile(configPath, 'utf-8');+ const config = JSON.parse(raw) as { leadSessionId?: string };+ if (config.leadSessionId === sessionId) {+ const inboxPath = path.join(TEAMS_DIR, teamName, 'inboxes', 'team-lead.json');+ return { teamName, inboxPath };+ }+ } catch {+ continue;+ }+ }+ return null;+}++/**+ * 读取 team-lead 收件箱中未读的消息(排除系统类型)+ */+async function readUnreadTeamLeadMessages(inboxPath: string): Promise<InboxMessage[]> {+ try {+ const raw = await fs.readFile(inboxPath, 'utf-8');+ const messages: InboxMessage[] = JSON.parse(raw);+ return messages.filter((m) => {+ if (m.read) return false;+ // 跳过纯系统通知(idle_notification、shutdown_*、permission_request)+ try {+ const parsed = JSON.parse(m.text) as Record<string, unknown>;+ const t = parsed.type;+ if (+ t === 'idle_notification' ||+ t === 'shutdown_request' ||+ t === 'shutdown_approved' ||+ t === 'permission_request'+ ) {+ return false;+ }+ } catch {+ // 不是 JSON,保留+ }+ return true;@@ -0,0 +1,579 @@+'use client';++import { useState, useRef, useEffect, useMemo } from 'react';+import { Button } from '@/components/ui/button';+import { Textarea } from '@/components/ui/textarea';+import { Card } from '@/components/ui/card';+import { Send, Square } from 'lucide-react';+import type { ChatMessage } from '@04-agent-teams/core';+import { SessionList } from './session-list';+import { FileExplorer } from './file-explorer';+import { MarkdownRenderer } from './markdown-renderer';+import { LoadingIndicator } from './ui/loading-indicator';+import { ToolActivityManager, type ToolActivity, type AgentActivityNode } from '@/lib/tool-activity';+import { AgentTeamStore, type TeammateState } from '@/lib/agent-team-store';+import { PermissionSelector, type PermissionRequest } from './permission-selector';+import { AgentTeamView } from './agent-team-view';+import { AgentTeamSplit } from './agent-team-split';+import { AgentTaskList } from './agent-task-list';+import type { AgentEvent } from '@04-agent-teams/shared/agent';+import type { AgentTeamData } from '@/app/api/agent-teams/route';++export function ChatInterface() {+ const [messages, setMessages] = useState<ChatMessage[]>([]);+ const [input, setInput] = useState('');+ const [isStreaming, setIsStreaming] = useState(false);+ const [sessionId, setSessionId] = useState<string | null>(null);+ const [refreshTrigger, setRefreshTrigger] = useState(0);+ const [toolActivities, setToolActivities] = useState<ToolActivity[]>([]);+ const [activityTree, setActivityTree] = useState<AgentActivityNode[]>([]);+ const [teammates, setTeammates] = useState<TeammateState[]>([]);+ const [permissionQueue, setPermissionQueue] = useState<PermissionRequest[]>([]);+ const [permissionMode, setPermissionMode] = useState<string>('default');+ const [agentTeamData, setAgentTeamData] = useState<AgentTeamData | null>(null);+ const scrollRef = useRef<HTMLDivElement>(null);++ // 用于终止请求的 refs+ const abortControllerRef = useRef<AbortController | null>(null);+ const readerRef = useRef<ReadableStreamDefaultReader<Uint8Array> | null>(null);++ // 创建 ToolActivityManager 实例+ const toolActivityManager = useMemo(() => new ToolActivityManager(), []);+ // 创建 AgentTeamStore 实例+ const agentTeamStore = useMemo(() => new AgentTeamStore(), []);++ // 订阅工具活动更新+ useEffect(() => {+ const unsubscribe = toolActivityManager.subscribe((activities) => {+ setToolActivities(activities);+ setActivityTree(toolActivityManager.getActivityTree());+ });+ return unsubscribe;+ }, [toolActivityManager]);++ // 订阅 Agent Team 更新+ useEffect(() => {+ const unsubscribe = agentTeamStore.subscribe(setTeammates);+ return unsubscribe;+ }, [agentTeamStore]);++ // 自动滚动到底部+ useEffect(() => {+ if (scrollRef.current) {+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;+ }+ }, [messages]);++ // 轮询 Agent Teams 任务列表(每 2 秒,仅在有 sessionId 且 streaming 时)+ useEffect(() => {+ if (!sessionId) return;+ const poll = async () => {+ try {+ const res = await fetch(`/api/agent-teams?sessionId=${sessionId}`);+ const data = await res.json() as AgentTeamData & { team: null | AgentTeamData['team'] };+ if (data.team) {+ setAgentTeamData(data as AgentTeamData);+ }+ } catch {+ // 忽略轮询错误+ }+ };+ poll();+ const id = setInterval(poll, 2000);+ return () => clearInterval(id);+ }, [sessionId, isStreaming]);++ // 加载选中的会话+ const loadSession = async (id: string) => {+ try {+ const response = await fetch(`/api/sessions/${id}`);@@ -0,0 +1,41 @@+import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk';++interface PendingPermission {+ resolve: (result: PermissionResult) => void;+ toolName: string;+ toolUseId: string;+}++// 使用 globalThis 确保跨 route 共享同一个 Map+const STORE_KEY = '__permission_pending_store__';++function getStore(): Map<string, PendingPermission> {+ const g = globalThis as Record<string, unknown>;+ if (!g[STORE_KEY]) {+ g[STORE_KEY] = new Map<string, PendingPermission>();+ }+ return g[STORE_KEY] as Map<string, PendingPermission>;+}++export function addPending(+ requestId: string,+ toolName: string,+ toolUseId: string,+): Promise<PermissionResult> {+ return new Promise((resolve) => {+ getStore().set(requestId, { resolve, toolName, toolUseId });+ });+}++export function resolvePending(requestId: string, result: PermissionResult): boolean {+ const store = getStore();+ const entry = store.get(requestId);+ if (!entry) return false;+ entry.resolve(result);+ store.delete(requestId);+ return true;+}++export function removePending(requestId: string): void {+ getStore().delete(requestId);+}