diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 470686fa..d1858124 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -9,6 +9,8 @@ import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.content.Media; import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; import org.springframework.util.MimeType; import reactor.core.publisher.Flux; import vip.mate.agent.context.ChatOrigin; @@ -25,6 +27,8 @@ import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.MessageMetadataJson; import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.conversation.model.MessageEntity; +import vip.mate.workspace.core.service.MemberFileAccess; +import vip.mate.workspace.core.service.MemberFileIsolation; import java.nio.file.Files; import java.nio.file.Path; @@ -980,6 +984,12 @@ public abstract class BaseAgent { : MultimodalRoutingDecision.none(); StringBuilder textBuilder = new StringBuilder(renderedContent == null ? "" : renderedContent); + if (MemberFileIsolation.isEnabled() + && decision.strategy() == MultimodalRoutingDecision.Strategy.SIDECAR) { + // The sidecar still loads host paths itself, so it cannot receive member attachments. + decision = MultimodalRoutingDecision.none(); + textBuilder.append("\n\n[系统提示] 成员文件隔离模式暂不支持图片旁路识别。请明确告知用户此限制,并建议使用原生支持图片输入的模型。"); + } java.util.Set sidecarHandledIdentifiers = new java.util.HashSet<>(); if (decision.strategy() == MultimodalRoutingDecision.Strategy.SIDECAR && mediaCaptionService != null @@ -1105,7 +1115,7 @@ public abstract class BaseAgent { } try { MimeType mimeType = MimeType.valueOf(contentType); - Media media = new Media(mimeType, new FileSystemResource(mediaPath)); + Media media = new Media(mimeType, mediaResource(mediaPath)); mediaList.add(media); log.debug("[{}] Injected {} into prompt: {} ({})", agentName, isVideo ? "video" : "image", part.getFileName(), mediaPath); @@ -1124,7 +1134,9 @@ public abstract class BaseAgent { // either. With a media tool the LLM may legitimately choose to // delegate to the tool — never instruct it not to use tools. if (!hasMediaCapableTools()) { - textBuilder.append("\n请用对话语言清晰、友好地告诉用户:当前模型无法处理这类附件,建议切换到具备相应能力的多模态模型,或在「设置 → 模型」中配置视觉/视频模型作为旁路。"); + textBuilder.append(MemberFileIsolation.isEnabled() + ? "\n请明确告知用户附件未能加载;成员文件隔离模式仅支持读取本人的文件,请使用原生支持此类附件的多模态模型,旁路识别暂不可用。" + : "\n请用对话语言清晰、友好地告诉用户:当前模型无法处理这类附件,建议切换到具备相应能力的多模态模型,或在「设置 → 模型」中配置视觉/视频模型作为旁路。"); } } @@ -1356,7 +1368,7 @@ public abstract class BaseAgent { String contentType = part.getContentType(); if (contentType == null || "image/*".equals(contentType)) contentType = "image/jpeg"; try { - Media carried = new Media(MimeType.valueOf(contentType), new FileSystemResource(imgPath)); + Media carried = new Media(MimeType.valueOf(contentType), mediaResource(imgPath)); UserMessage orig = built.userMessage(); String name = part.getFileName() == null ? "image" : part.getFileName(); String text = (orig.getText() == null ? "" : orig.getText()) @@ -1400,10 +1412,27 @@ public abstract class BaseAgent { return isImage && !contentType.contains("svg"); } + private Resource mediaResource(Path path) throws java.io.IOException { + if (!MemberFileIsolation.isEnabled()) return new FileSystemResource(path); + Path root = Path.of(MemberFileIsolation.scope(ChatOriginHolder.get()).workspaceBasePath()); + // Snapshot through descriptor-relative access; never defer a host path read to the model client. + return new ByteArrayResource(MemberFileAccess.read(root, path, 32 * 1024 * 1024)); + } + protected Path resolveImagePath(String relativePath) { if (relativePath == null || relativePath.isBlank()) { return null; } + if (MemberFileIsolation.isEnabled()) { + try { + Path root = Path.of(MemberFileIsolation.scope(ChatOriginHolder.get()).workspaceBasePath()); + Path resolved = MemberFileIsolation.validate(root, relativePath); + return Files.isRegularFile(resolved, java.nio.file.LinkOption.NOFOLLOW_LINKS) ? resolved : null; + } catch (SecurityException | IllegalArgumentException denied) { + log.debug("[{}] Member media path rejected", agentName); + return null; + } + } // 1. 如果已经是绝对路径且存在,直接用 Path path = Paths.get(relativePath); if (path.isAbsolute() && Files.exists(path)) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java index 760796b7..64f54407 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ChatOrigin.java @@ -182,6 +182,13 @@ public record ChatOrigin( executionAttribution, selectedGoalId); } + /** Carry a user identity recovered from trusted persisted conversation ownership. */ + public ChatOrigin withRequesterUserId(Long userId) { + return new ChatOrigin(agentId, conversationId, requesterId, workspaceId, workspaceBasePath, + channelId, channelTarget, cronOrigin, senderName, channelType, chatId, baseUrl, + userId, originMessageId, executionAttribution, selectedGoalId); + } + public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId, @Nullable String newWorkspaceBasePath) { return new ChatOrigin(agentId, conversationId, requesterId, diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index 2d08592e..5fe86f25 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -1,5 +1,6 @@ package vip.mate.agent.graph; +import vip.mate.workspace.core.service.MemberFileIsolation; import com.alibaba.cloud.ai.graph.CompiledGraph; import com.alibaba.cloud.ai.graph.NodeOutput; import com.alibaba.cloud.ai.graph.OverAllState; @@ -600,6 +601,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC } private Map buildInitialState(String userMessage, String conversationId) { + var incoming = vip.mate.agent.context.ChatOriginHolder.get().withConversationId(conversationId); + var memberOrigin = MemberFileIsolation.scope(incoming.withWorkspace(incoming.workspaceId(), this.workspaceBasePath)); + String workspaceBasePath = memberOrigin.workspaceBasePath(); // 加载会话历史 List historyMessages = buildConversationHistory(conversationId, userMessage); @@ -685,6 +689,8 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC } origin = origin.withConversationId(conversationId) .withWorkspace(origin.workspaceId(), workspaceBasePath); + origin = MemberFileIsolation.scope(origin); + inputs.put(WORKSPACE_BASE_PATH, origin.workspaceBasePath() != null ? origin.workspaceBasePath() : ""); inputs.put(CHAT_ORIGIN, origin); // RFC 48 — inject active goal snapshot for GoalEvaluationNode. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index 99a5e3fe..31f5749f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -1,5 +1,6 @@ package vip.mate.agent.graph.executor; +import vip.mate.workspace.core.service.MemberFileIsolation; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.messages.AssistantMessage; @@ -493,6 +494,8 @@ public class ToolExecutionExecutor { if (isBlank(safeOrigin.workspaceBasePath()) && !isBlank(workspaceBasePath)) { safeOrigin = safeOrigin.withWorkspace(safeOrigin.workspaceId(), workspaceBasePath); } + safeOrigin = MemberFileIsolation.scope(safeOrigin); + if (MemberFileIsolation.isEnabled()) workspaceBasePath = safeOrigin.workspaceBasePath(); // Reset per-turn audit dedupe state. A retried denied tool inside the // same turn writes a single audit row; the set is repopulated by the // denial branch below. @@ -579,6 +582,12 @@ public class ToolExecutionExecutor { // bypass guard rules keyed on the canonical name. String toolName = resolveToolName(toolCall.name()); String arguments = toolCall.arguments(); + try { MemberFileIsolation.requireAuditedTool(toolName); } + catch (SecurityException denied) { + allResponses.add(new ToolResponseMessage.ToolResponse(toolCall.id(), responseName, denied.getMessage())); + events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, denied.getMessage(), false)); + continue; + } events.add(GraphEventPublisher.toolStart(toolCall.id(), toolName, arguments)); @@ -805,6 +814,7 @@ public class ToolExecutionExecutor { String conversationId, String workspaceBasePath, List directOutputs, ChatOrigin origin) { String toolName = resolveToolName(toolCall.name()); + MemberFileIsolation.requireAuditedTool(toolName); String callArguments = storedArguments != null ? storedArguments : toolCall.arguments(); ToolCallback callback = toolCallbackMap.get(toolName); @@ -842,6 +852,8 @@ public class ToolExecutionExecutor { ChatOrigin replayOrigin = (origin == null ? ChatOrigin.EMPTY : origin) .withConversationId(conversationId); replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath); + replayOrigin = MemberFileIsolation.scope(replayOrigin); + if (MemberFileIsolation.isEnabled()) workspaceBasePath = replayOrigin.workspaceBasePath(); String result = invokeObserved(callback, callArguments, toolContextWithScopedCatalog(replayOrigin), UUID.randomUUID().toString(), toolCall.id()); throwIfStopRequested(conversationId); @@ -1645,6 +1657,7 @@ public class ToolExecutionExecutor { * to the usual {@code skillAwareNotFoundMessage} hint. */ private SkillRedirect tryAutoRedirectSkillCall(String toolName, String originalArgs, ChatOrigin origin) { + if (MemberFileIsolation.isEnabled()) return null; if (skillRuntimeService == null || toolName == null || toolName.isBlank()) return null; try { // Scope to the conversation's workspace so an agent is never redirected @@ -1795,9 +1808,20 @@ public class ToolExecutionExecutor { private String invokeObserved(ToolCallback callback, String arguments, ToolContext context, String invocationKey, String providerCallId) throws TimeoutException { String toolName = callback.getToolDefinition().name(); - return ToolCallDeadline.call(toolName, getToolTimeoutMs(toolName), - () -> executionEvidenceRecorder == null ? callback.call(arguments, context) - : executionEvidenceRecorder.invoke(callback, arguments, context, invocationKey, providerCallId)); + MemberFileIsolation.requireAuditedTool(toolName); + ChatOrigin scoped = MemberFileIsolation.scope(ChatOrigin.from(context)); + java.util.Map values = new java.util.HashMap<>(context.getContext()); + values.put(ChatOrigin.CTX_KEY, scoped); + ToolContext scopedContext = new ToolContext(values); + return ToolCallDeadline.call(toolName, getToolTimeoutMs(toolName), () -> { + // Deadlines may use another thread; install the identity inside the callback. + ToolExecutionContext.set(scoped.conversationId(), scoped.requesterId(), scoped.workspaceBasePath()); + ToolExecutionContext.setOrigin(scoped); + try { + return executionEvidenceRecorder == null ? callback.call(arguments, scopedContext) + : executionEvidenceRecorder.invoke(callback, arguments, scopedContext, invocationKey, providerCallId); + } finally { ToolExecutionContext.clear(); } + }); } // ==================== 内部数据类 ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java index cb85bd48..062e9b6c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolResultStorage.java @@ -1,5 +1,6 @@ package vip.mate.agent.graph.executor; +import vip.mate.workspace.core.service.MemberFileIsolation; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.messages.ToolResponseMessage; @@ -165,8 +166,7 @@ public class ToolResultStorage { return result; } try { - Files.createDirectories(file.getParent()); - Files.writeString(file, result, StandardCharsets.UTF_8); + writeSpill(file, result, workspaceBasePath); } catch (IOException ioe) { log.warn("[ToolResultStorage] spill write failed for tool={} convId={} ({}); keeping original", toolName, conversationId, ioe.getMessage()); @@ -231,8 +231,7 @@ public class ToolResultStorage { break; } try { - Files.createDirectories(file.getParent()); - Files.writeString(file, target.responseData(), StandardCharsets.UTF_8); + writeSpill(file, target.responseData(), workspaceBasePath); } catch (IOException ioe) { log.warn("[ToolResultStorage] spill write failed during turn budget enforcement: {}", ioe.getMessage()); @@ -312,6 +311,15 @@ public class ToolResultStorage { * Resolve the spill file path for a given (conversationId, toolUseId). * Returns {@code null} if no usable directory can be determined. */ + private void writeSpill(Path file, String text, String workspaceBasePath) throws IOException { + if (MemberFileIsolation.isEnabled()) { + vip.mate.workspace.core.service.MemberFileAccess.write(Path.of(workspaceBasePath), file, text.getBytes(StandardCharsets.UTF_8), false); + } else { + Files.createDirectories(file.getParent()); + Files.writeString(file, text, StandardCharsets.UTF_8); + } + } + private Path spillFor(String conversationId, String toolUseId, String workspaceBasePath) { String safeConv = sanitize(conversationId); String safeId = sanitize(toolUseId); @@ -326,7 +334,11 @@ public class ToolResultStorage { private Path resolveBaseDir(String workspaceBasePath) { Path base; boolean outsideWorkspace; - if (!props.getStorageBaseDir().isEmpty()) { + if (MemberFileIsolation.isEnabled()) { + if (workspaceBasePath == null || workspaceBasePath.isBlank()) return null; + base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results"); + outsideWorkspace = false; + } else if (!props.getStorageBaseDir().isEmpty()) { base = Paths.get(props.getStorageBaseDir()); outsideWorkspace = true; } else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) { @@ -374,6 +386,8 @@ public class ToolResultStorage { * pointed at the historical workspace. */ public int cleanupExpired() { + // Legacy traversal cannot safely follow directories writable by confined code. + if (MemberFileIsolation.isEnabled()) return 0; if (props.getRetentionDays() <= 0) { return 0; } @@ -452,6 +466,7 @@ public class ToolResultStorage { * is simply left alone. Returns the number of files deleted. */ public int purgeConversation(String conversationId) { + if (MemberFileIsolation.isEnabled()) return 0; if (conversationId == null || conversationId.isEmpty()) { return 0; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java index 8c3508d7..57c21250 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java @@ -1,5 +1,6 @@ package vip.mate.agent.graph.plan; +import vip.mate.workspace.core.service.MemberFileIsolation; import com.alibaba.cloud.ai.graph.CompiledGraph; import com.alibaba.cloud.ai.graph.RunnableConfig; import lombok.extern.slf4j.Slf4j; @@ -317,6 +318,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS } private Map buildInitialState(String userMessage, String conversationId) { + var incoming = vip.mate.agent.context.ChatOriginHolder.get().withConversationId(conversationId); + var memberOrigin = MemberFileIsolation.scope(incoming.withWorkspace(incoming.workspaceId(), this.workspaceBasePath)); + String workspaceBasePath = memberOrigin.workspaceBasePath(); // 加载会话历史(复用 BaseAgent.buildConversationHistory,与 ReAct 对齐) List historyMessages = buildConversationHistory(conversationId, userMessage); @@ -386,6 +390,8 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS } origin = origin.withConversationId(conversationId) .withWorkspace(origin.workspaceId(), workspaceBasePath); + origin = MemberFileIsolation.scope(origin); + inputs.put(MateClawStateKeys.WORKSPACE_BASE_PATH, origin.workspaceBasePath() != null ? origin.workspaceBasePath() : ""); inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin); // RFC 48 — inject active goal snapshot for GoalEvaluationNode. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java index 6d2cbd43..8b7bb997 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java @@ -165,6 +165,9 @@ public class DshRuntimeService implements AgentRuntimeProvider { @Override public AgentRuntimeConnection start(RuntimeSession session) { + if (vip.mate.workspace.core.service.MemberFileIsolation.isEnabled()) { + throw new SecurityException("DSH runtime is unavailable in member file isolation mode"); + } RuntimeValidation validation = validate(session); if (!validation.valid()) { throw new IllegalArgumentException(validation.code() + ": " + validation.message()); @@ -232,6 +235,9 @@ public class DshRuntimeService implements AgentRuntimeProvider { Path workingDirectory, AtomicReference processRef, AtomicReference latestUsage) { + if (vip.mate.workspace.core.service.MemberFileIsolation.isEnabled()) { + return Flux.error(new SecurityException("DSH runtime is unavailable in member file isolation mode")); + } return Flux.create(sink -> { Process process = null; try { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatcher.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatcher.java index b2f35136..d22b4348 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatcher.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatcher.java @@ -1,5 +1,6 @@ package vip.mate.agent.runtime.dsh; +import vip.mate.workspace.core.service.MemberFileIsolation; import org.springframework.ai.tool.ToolCallback; import java.nio.file.Path; @@ -28,6 +29,7 @@ public final class DshToolDispatcher { } public DshToolDispatchResult dispatch(String toolName, String argumentsJson, Path targetPath) { + if (MemberFileIsolation.isEnabled()) return DshToolDispatchResult.denied("DSH tools do not support member file isolation"); ToolCallback callback = callbacks.get(toolName); if (callback == null) return DshToolDispatchResult.denied("unknown tool"); DshToolDecision decision = policyEvaluator.decide(policy, toolName, targetPath); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index ce7fff4e..c3bc6097 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -1,5 +1,6 @@ package vip.mate.channel.web; +import vip.mate.workspace.core.service.MemberFileIsolation; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import com.fasterxml.jackson.databind.ObjectMapper; @@ -1222,10 +1223,20 @@ public class ChatController { @Operation(summary = "上传聊天附件") @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public R upload( + public R uploadInWorkspace( @RequestParam String conversationId, @RequestPart("file") MultipartFile file, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, Authentication auth) throws IOException { + return uploadForWorkspace(conversationId, file, workspaceId, auth); + } + + public R upload(String conversationId, MultipartFile file, Authentication auth) throws IOException { + return uploadForWorkspace(conversationId, file, null, auth); + } + + private R uploadForWorkspace(String conversationId, MultipartFile file, + Long workspaceId, Authentication auth) throws IOException { String username = auth != null ? auth.getName() : "anonymous"; // 校验会话归属(会话可能尚未创建,此时允许上传——后续 stream/chat 会创建并绑定用户)。 @@ -1242,14 +1253,23 @@ public class ChatController { String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file"; String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_"); String storedName = System.currentTimeMillis() + "_" + safeFilename; - Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId); + var uploadOrigin = vip.mate.agent.context.ChatOrigin.web(conversationId, username, + workspaceId != null ? workspaceId : (conversationService.conversationExists(conversationId) ? null : 1L), + null, null, requesterUserIdOf(auth)); + Path uploadRoot = MemberFileIsolation.isEnabled() ? uploadLocationResolver.resolveUploadRoot(uploadOrigin) + : uploadLocationResolver.resolveUploadRoot(conversationId); // resolveWriteDir sanitizes the id (IM-channel ids like "wecom:XXXX" // carry a ':' illegal on Windows) and appends the per-day sub-directory // when date folders are enabled. Reads probe both layouts. - Path writeDir = uploadLocationResolver.resolveWriteDir(conversationId); - Files.createDirectories(writeDir); + Path writeDir = MemberFileIsolation.isEnabled() ? uploadLocationResolver.resolveWriteDir(uploadOrigin) + : uploadLocationResolver.resolveWriteDir(conversationId); Path target = writeDir.resolve(storedName); - file.transferTo(target); + if (MemberFileIsolation.isEnabled()) { + vip.mate.workspace.core.service.MemberFileAccess.write(uploadRoot.getParent(), target, file.getBytes(), false); + } else { + Files.createDirectories(writeDir); + file.transferTo(target); + } log.info("Chat attachment uploaded: conversationId={}, user={}, file={}", conversationId, username, target); @@ -1278,12 +1298,25 @@ public class ChatController { return ResponseEntity.status(403).build(); } - Path filePath = resolveUploadedFile(conversationId, storedName); + if (MemberFileIsolation.isEnabled()) { + try { + MemberFileIsolation.scope(vip.mate.agent.context.ChatOrigin.web(conversationId, username, + null, null, null, requesterUserIdOf(auth))); + } catch (SecurityException e) { return ResponseEntity.status(403).build(); } + } + Path filePath = MemberFileIsolation.isEnabled() + ? uploadLocationResolver.resolveExistingFile(vip.mate.agent.context.ChatOrigin.web(conversationId, username, + null, null, null, requesterUserIdOf(auth)), storedName) + : resolveUploadedFile(conversationId, storedName); if (filePath == null) { return ResponseEntity.notFound().build(); } - Resource resource = new FileSystemResource(filePath); + Resource resource = MemberFileIsolation.isEnabled() + ? new org.springframework.core.io.ByteArrayResource(vip.mate.workspace.core.service.MemberFileAccess.read( + Path.of(MemberFileIsolation.scope(vip.mate.agent.context.ChatOrigin.web(conversationId, username, + null, null, null, requesterUserIdOf(auth))).workspaceBasePath()), filePath, 32 * 1024 * 1024)) + : new FileSystemResource(filePath); String contentType = Files.probeContentType(filePath); // probeContentType 在部分平台不识别视频格式,通过扩展名 fallback if (contentType == null) { @@ -1318,6 +1351,8 @@ public class ChatController { return ResponseEntity.status(403).build(); } + // Office converters execute outside the isolated runtime; do not launch them in strict mode. + if (MemberFileIsolation.isEnabled()) return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); // 415: the client asked to preview a format this endpoint won't convert. if (!officePreviewService.isConvertible(storedName)) { return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build(); @@ -1327,7 +1362,16 @@ public class ChatController { return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); } - Path filePath = resolveUploadedFile(conversationId, storedName); + if (MemberFileIsolation.isEnabled()) { + try { + MemberFileIsolation.scope(vip.mate.agent.context.ChatOrigin.web(conversationId, username, + null, null, null, requesterUserIdOf(auth))); + } catch (SecurityException e) { return ResponseEntity.status(403).build(); } + } + Path filePath = MemberFileIsolation.isEnabled() + ? uploadLocationResolver.resolveExistingFile(vip.mate.agent.context.ChatOrigin.web(conversationId, username, + null, null, null, requesterUserIdOf(auth)), storedName) + : resolveUploadedFile(conversationId, storedName); if (filePath == null) { return ResponseEntity.notFound().build(); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillScriptExecutionService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillScriptExecutionService.java index 78732e4f..baa8c77b 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillScriptExecutionService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillScriptExecutionService.java @@ -1,5 +1,6 @@ package vip.mate.skill.runtime; +import vip.mate.workspace.core.service.MemberFileIsolation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -110,6 +111,7 @@ public class SkillScriptExecutionService { */ public ScriptResult executeCode(String language, String code, Path workingDir, List args, Map envVars, Long timeoutSeconds) { + if (MemberFileIsolation.isEnabled()) return ScriptResult.error(-1, "Use the isolated code executor in member isolation mode"); if (code == null || code.isBlank()) { return ScriptResult.error(-1, "No code supplied"); } @@ -162,6 +164,7 @@ public class SkillScriptExecutionService { private ScriptResult executeResolved(Path scriptPath, List args, Map envVars, long timeoutSeconds, boolean scrubSensitiveEnv) { + if (MemberFileIsolation.isEnabled()) return ScriptResult.error(-1, "Skill script execution is unavailable in member isolation mode"); if (!Files.exists(scriptPath) || !Files.isRegularFile(scriptPath)) { return ScriptResult.error(-1, "Script not found: " + scriptPath); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java index 5362a982..89cce969 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java @@ -10,6 +10,13 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import vip.mate.tool.model.ToolEntity; import vip.mate.tool.repository.ToolMapper; +import vip.mate.workspace.core.service.MemberFileIsolation; +import vip.mate.tool.builtin.ReadFileTool; +import vip.mate.tool.builtin.WriteFileTool; +import vip.mate.tool.builtin.AppendFileTool; +import vip.mate.tool.builtin.EditFileTool; +import vip.mate.tool.builtin.ShellExecuteTool; +import vip.mate.tool.builtin.CodeExecuteTool; import org.springframework.ai.support.ToolCallbacks; import org.springframework.ai.tool.ToolCallback; @@ -53,7 +60,12 @@ public class ToolRegistry { private final CopyOnWriteArrayList pluginTools = new CopyOnWriteArrayList<>(); private final Object enabledToolSetLock = new Object(); - private volatile AgentToolSet enabledToolSetCache; + private record CachedToolSet(AgentToolSet tools, boolean isolated) { } + private volatile CachedToolSet enabledToolSetCache; + // Exact classes only: a same-name method, provider callback, or subclass is not audited. + private static final Set> MEMBER_ISOLATED_BEANS = Set.of( + ReadFileTool.class, WriteFileTool.class, AppendFileTool.class, + EditFileTool.class, ShellExecuteTool.class, CodeExecuteTool.class); /** A tool entry registered by a plugin */ public record PluginToolEntry(ToolCallback callback, Supplier availabilityCheck) {} @@ -83,6 +95,7 @@ public class ToolRegistry { * through the same {@code mate_agent_tool.tool_name} path as other tools. */ public List listAvailablePluginTools() { + if (MemberFileIsolation.isEnabled()) return List.of(); List out = new ArrayList<>(); for (PluginToolEntry entry : pluginTools) { try { @@ -174,6 +187,9 @@ public class ToolRegistry { for (Map.Entry entry : beans.entrySet()) { String beanName = entry.getKey(); Object bean = entry.getValue(); + if (enabledOnly && MemberFileIsolation.isEnabled() && !MEMBER_ISOLATED_BEANS.contains(bean.getClass())) { + continue; + } boolean hasToolMethod = java.util.Arrays.stream(bean.getClass().getMethods()) .anyMatch(m -> m.isAnnotationPresent(Tool.class)); @@ -281,17 +297,18 @@ public class ToolRegistry { * 2. 当前容器中所有 ToolCallbackProvider(MCP server 等) */ public AgentToolSet getEnabledToolSet() { - AgentToolSet cached = enabledToolSetCache; - if (cached != null) { - return cached; + CachedToolSet cached = enabledToolSetCache; + if (cached != null && cached.isolated() == MemberFileIsolation.isEnabled()) { + return cached.tools(); } synchronized (enabledToolSetLock) { cached = enabledToolSetCache; - if (cached != null) { - return cached; + if (cached != null && cached.isolated() == MemberFileIsolation.isEnabled()) { + return cached.tools(); } + boolean isolated = MemberFileIsolation.isEnabled(); AgentToolSet built = buildEnabledToolSet(); - enabledToolSetCache = built; + enabledToolSetCache = new CachedToolSet(built, isolated); return built; } } @@ -307,7 +324,8 @@ public class ToolRegistry { nameByBean.put(e.getValue(), e.getKey()); } - Map providerBeans = applicationContext.getBeansOfType(ToolCallbackProvider.class); + Map providerBeans = MemberFileIsolation.isEnabled() + ? Map.of() : applicationContext.getBeansOfType(ToolCallbackProvider.class); List providers = new ArrayList<>(providerBeans.values()); // 对内置工具 callback 应用 i18n 描述包装 @@ -340,7 +358,7 @@ public class ToolRegistry { // Plugin tool callbacks — evaluate availability checks lazily int pluginToolCount = 0; - for (PluginToolEntry entry : pluginTools) { + for (PluginToolEntry entry : MemberFileIsolation.isEnabled() ? List.of() : pluginTools) { try { if (Boolean.TRUE.equals(entry.availabilityCheck().get())) { localizedCallbacks.add(entry.callback()); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/AppendFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/AppendFileTool.java index 33c96b80..fb4af92a 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/AppendFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/AppendFileTool.java @@ -1,5 +1,7 @@ package vip.mate.tool.builtin; +import vip.mate.workspace.core.service.MemberFileIsolation; +import vip.mate.workspace.core.service.MemberFileAccess; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.RequiredArgsConstructor; @@ -52,10 +54,12 @@ public class AppendFileTool { } Path parent = path.getParent(); - if (parent != null) Files.createDirectories(parent); + if (!MemberFileIsolation.isEnabled() && parent != null) Files.createDirectories(parent); boolean existed = Files.exists(path); - String current = existed ? Files.readString(path, StandardCharsets.UTF_8) : ""; + String current = existed ? (MemberFileIsolation.isEnabled() + ? new String(MemberFileAccess.read(MemberFileIsolation.root(ctx), path, 32 * 1024 * 1024), StandardCharsets.UTF_8) + : Files.readString(path, StandardCharsets.UTF_8)) : ""; if (current.endsWith(content)) { JSONObject result = baseResult(filePath); result.set("bytesWritten", 0); @@ -70,7 +74,8 @@ public class AppendFileTool { } byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - Files.write(path, bytes, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + if (MemberFileIsolation.isEnabled()) MemberFileAccess.write(MemberFileIsolation.root(ctx), path, bytes, true); + else Files.write(path, bytes, StandardOpenOption.CREATE, StandardOpenOption.APPEND); JSONObject result = baseResult(filePath); result.set("bytesWritten", bytes.length); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java index 21e3a4d1..116ee95e 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java @@ -1,5 +1,6 @@ package vip.mate.tool.builtin; +import vip.mate.workspace.core.service.MemberFileIsolation; import lombok.extern.slf4j.Slf4j; import vip.mate.workspace.core.service.ChatUploadLocationResolver; @@ -83,7 +84,8 @@ public final class ChatUploadResolver { for (Path uploadDir : candidateUploadDirs(conversationId)) { Path matched = resolveIn(rawPath, uploadDir); if (matched != null) { - return matched; + return MemberFileIsolation.isEnabled() + ? MemberFileIsolation.validate(MemberFileIsolation.root(null), matched.toString()) : matched; } } return null; @@ -97,6 +99,11 @@ public final class ChatUploadResolver { */ private static List candidateUploadDirs(String conversationId) { Set dirs = new LinkedHashSet<>(); + if (MemberFileIsolation.isEnabled()) { + Path member = MemberFileIsolation.root(null); + Path conversation = member.resolve(UPLOAD_SUBDIR).resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)); + return List.of(MemberFileIsolation.validate(member, conversation.toString())); + } String basePath = ToolExecutionContext.workspaceBasePath(); if (basePath != null && !basePath.isBlank()) { Path scopedRoot = Paths.get(basePath).toAbsolutePath().normalize().resolve(UPLOAD_SUBDIR); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java index 1ecec6c8..878c7e07 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java @@ -64,6 +64,9 @@ public class CodeExecuteTool { private final ObjectMapper objectMapper; private final GeneratedFileCache generatedFileCache; + @Autowired + private vip.mate.tool.sandbox.MemberSandboxExecutor memberSandboxExecutor; + @Lazy @Autowired private AgentBindingResolver agentBindingResolver; @@ -117,6 +120,9 @@ public class CodeExecuteTool { log.info("[CodeExecute] language={}, skill={}, codeChars={}", language, skillName, code == null ? 0 : code.length()); + if (vip.mate.workspace.core.service.MemberFileIsolation.isEnabled()) { + return executeIsolated(language, code, skillName, args, timeoutSeconds, ctx); + } Path workingDir; Map envVars = Collections.emptyMap(); @@ -178,6 +184,28 @@ public class CodeExecuteTool { * argument per element, anything else is forwarded verbatim as a single * argument (so a bare date / version string is never mangled by JSON parsing). */ + private String executeIsolated(String language, String code, String skillName, String args, + Integer timeoutSeconds, ToolContext ctx) { + if (skillName != null && !skillName.isBlank()) return formatError("Shared skill execution is unavailable in member isolation mode"); + try { + var scoped = vip.mate.workspace.core.service.MemberFileIsolation.scope(ChatOrigin.from(ctx)); + Path root = Path.of(scoped.workspaceBasePath()); + long since = System.currentTimeMillis(); + var output = memberSandboxExecutor.executeCode(language, code, root, normalizeArgs(args), Map.of(), + timeoutSeconds == null || timeoutSeconds <= 0 ? 30 : timeoutSeconds); + var json = objectMapper.readTree(formatResult(new SkillScriptExecutionService.ScriptResult( + output.exitCode(), output.stdout(), output.stderr()), + WorkspaceArtifactSurfacer.collect(generatedFileCache, root, since, scoped.toToolContext()))); + ((com.fasterxml.jackson.databind.node.ObjectNode) json).put("timedOut", output.timedOut()); + return objectMapper.writeValueAsString(json); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return formatError("Isolated execution cancelled"); + } catch (Exception e) { + return formatError("Isolated execution failed: " + e.getMessage()); + } + } + List normalizeArgs(String args) { if (args == null) { return null; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java index c73fadd7..2c59d7c2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java @@ -1,5 +1,7 @@ package vip.mate.tool.builtin; +import vip.mate.workspace.core.service.MemberFileIsolation; +import vip.mate.workspace.core.service.MemberFileAccess; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.extern.slf4j.Slf4j; @@ -84,7 +86,9 @@ public class EditFileTool { } // 读取文件内容 - String content = Files.readString(path, StandardCharsets.UTF_8); + String content = MemberFileIsolation.isEnabled() + ? new String(MemberFileAccess.read(MemberFileIsolation.root(ctx), path, 32 * 1024 * 1024), StandardCharsets.UTF_8) + : Files.readString(path, StandardCharsets.UTF_8); // 检查 oldText 是否存在 if (!content.contains(oldText)) { @@ -108,7 +112,8 @@ public class EditFileTool { } // 写回文件 - Files.writeString(path, newContent, StandardCharsets.UTF_8); + if (MemberFileIsolation.isEnabled()) MemberFileAccess.write(MemberFileIsolation.root(ctx), path, newContent.getBytes(StandardCharsets.UTF_8), false); + else Files.writeString(path, newContent, StandardCharsets.UTF_8); result.set("replacements", replacements); result.set("replaceAll", doReplaceAll); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java index dc81781c..dc7a1450 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ReadFileTool.java @@ -1,5 +1,7 @@ package vip.mate.tool.builtin; +import vip.mate.workspace.core.service.MemberFileIsolation; +import vip.mate.workspace.core.service.MemberFileAccess; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.extern.slf4j.Slf4j; @@ -112,7 +114,9 @@ public class ReadFileTool { } // 读取所有行 - List allLines = readLinesUtf8(path); + List allLines = MemberFileIsolation.isEnabled() + ? new String(MemberFileAccess.read(MemberFileIsolation.root(ctx), path, 32 * 1024 * 1024), StandardCharsets.UTF_8).lines().toList() + : readLinesUtf8(path); int totalLines = allLines.size(); result.set("totalLines", totalLines); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java index d20bbd63..4364fabc 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java @@ -47,6 +47,9 @@ public class ShellExecuteTool { private final vip.mate.i18n.I18nService i18n; private final GeneratedFileCache generatedFileCache; + @org.springframework.beans.factory.annotation.Autowired + private vip.mate.tool.sandbox.MemberSandboxExecutor memberSandboxExecutor; + private static final int DEFAULT_TIMEOUT_SECONDS = 60; private static final int MAX_OUTPUT_BYTES = 10_000; private static final boolean IS_WINDOWS = System.getProperty("os.name", "") @@ -64,6 +67,9 @@ public class ShellExecuteTool { // ChatOrigin so the workspace boundary check honors per-agent basePath. @Nullable ToolContext ctx) { + if (vip.mate.workspace.core.service.MemberFileIsolation.isEnabled()) { + return executeIsolated(command, timeoutSeconds, ctx); + } ExecutionObservationSink evidence = ExecutionObservationSink.from(ctx); int timeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : DEFAULT_TIMEOUT_SECONDS; // 硬上限:不允许超过 300 秒 @@ -195,6 +201,35 @@ public class ShellExecuteTool { * from the calling environment still apply; falls back to /bin/sh * when $SHELL is unset or points at a non-executable path. */ + private String executeIsolated(String command, Integer timeoutSeconds, ToolContext ctx) { + JSONObject result = new JSONObject(); + result.set("command", command); + ExecutionObservationSink evidence = ExecutionObservationSink.from(ctx); + try { + var scoped = vip.mate.workspace.core.service.MemberFileIsolation.scope(vip.mate.agent.context.ChatOrigin.from(ctx)); + Path root = Path.of(scoped.workspaceBasePath()); + long since = System.currentTimeMillis(); + var output = memberSandboxExecutor.execute(List.of("/bin/sh", "-c", command), root, java.util.Map.of(), + timeoutSeconds == null || timeoutSeconds <= 0 ? DEFAULT_TIMEOUT_SECONDS : timeoutSeconds); + result.set("exitCode", output.exitCode()); + result.set("stdout", output.stdout()); + result.set("stderr", output.stderr()); + result.set("timedOut", output.timedOut()); + if (evidence != null) evidence.command(output.exitCode(), output.timedOut(), false, false, root.toString()); + List files = WorkspaceArtifactSurfacer.collect(generatedFileCache, root, since, scoped.toToolContext()); + if (!files.isEmpty()) result.set("generatedFiles", String.join("\n", files)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + result.set("exitCode", -1); result.set("cancelled", true); result.set("stderr", "Command cancelled"); + if (evidence != null) evidence.command(null, false, true, false); + } catch (Exception e) { + result.set("exitCode", -1); result.set("stdout", ""); result.set("timedOut", false); + result.set("stderr", "Isolated execution failed: " + e.getMessage()); + if (evidence != null) evidence.command(null, false, false, true); + } + return JSONUtil.toJsonPrettyStr(result); + } + private static ProcessBuilder buildShellProcess(String command, @Nullable ToolContext ctx) { ProcessBuilder pb; if (IS_WINDOWS) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java index 9b2309a9..1d837a57 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ToolExecutionContext.java @@ -20,6 +20,8 @@ import vip.mate.agent.context.ChatOrigin; */ public final class ToolExecutionContext { + private static final ThreadLocal ORIGIN = new ThreadLocal<>(); + private static final ThreadLocal CONVERSATION_ID = new ThreadLocal<>(); private static final ThreadLocal USERNAME = new ThreadLocal<>(); /** 工作区活动目录(为空不限制) */ @@ -28,12 +30,14 @@ public final class ToolExecutionContext { private ToolExecutionContext() {} public static void set(String conversationId, String username) { + ORIGIN.remove(); CONVERSATION_ID.set(conversationId); USERNAME.set(username); WORKSPACE_BASE_PATH.remove(); } public static void set(String conversationId, String username, String workspaceBasePath) { + ORIGIN.remove(); CONVERSATION_ID.set(conversationId); USERNAME.set(username); WORKSPACE_BASE_PATH.set(workspaceBasePath); @@ -52,7 +56,11 @@ public final class ToolExecutionContext { return WORKSPACE_BASE_PATH.get(); } + public static void setOrigin(ChatOrigin origin) { ORIGIN.set(origin); } + public static ChatOrigin origin() { return ORIGIN.get(); } + public static void clear() { + ORIGIN.remove(); CONVERSATION_ID.remove(); USERNAME.remove(); WORKSPACE_BASE_PATH.remove(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java index b7edc4e1..953cfd63 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java @@ -1,5 +1,7 @@ package vip.mate.tool.builtin; +import vip.mate.workspace.core.service.MemberFileIsolation; +import vip.mate.workspace.core.service.MemberFileAccess; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.extern.slf4j.Slf4j; @@ -71,7 +73,7 @@ public class WriteFileTool { // 自动创建父目录 Path parent = path.getParent(); - if (parent != null && !Files.exists(parent)) { + if (!MemberFileIsolation.isEnabled() && parent != null && !Files.exists(parent)) { Files.createDirectories(parent); log.info("[WriteFile] Created parent directories: {}", parent); } @@ -80,7 +82,8 @@ public class WriteFileTool { // 写入文件 byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - Files.write(path, bytes); + if (MemberFileIsolation.isEnabled()) MemberFileAccess.write(MemberFileIsolation.root(ctx), path, bytes, false); + else Files.write(path, bytes); result.set("bytesWritten", bytes.length); result.set("created", !existed); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java index f8b5af5c..36415e37 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java @@ -1,5 +1,6 @@ package vip.mate.tool.document; +import vip.mate.workspace.core.service.MemberFileIsolation; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -45,7 +46,9 @@ public class GeneratedFileController { if (user == null) { return ResponseEntity.status(401).body(Map.of("error", "Unauthorized")); } - var access = cache.getAuthorized(id, owner -> canDownload(owner.workspaceId(), workspaceId, user)); + var access = cache.getAuthorized(id, owner -> (!MemberFileIsolation.isEnabled() + || (owner.workspaceId() != null && owner.ownerUserId() != null && owner.ownerUserId().equals(user.getId()))) + && canDownload(owner.workspaceId(), workspaceId, user)); if (access.status() == GeneratedFileCache.AccessStatus.FORBIDDEN) { return ResponseEntity.status(403).body(Map.of("error", "Workspace permission denied")); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java b/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java index 1c83dae1..727625ed 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java @@ -1,5 +1,7 @@ package vip.mate.tool.document; +import vip.mate.workspace.core.service.MemberFileIsolation; +import vip.mate.workspace.core.service.MemberFileAccess; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.model.ToolContext; import org.springframework.lang.Nullable; @@ -71,7 +73,8 @@ public final class WorkspaceArtifactSurfacer { if (!attrs.isRegularFile() || size <= 0 || size > budget) { continue; } - byte[] bytes = readArtifact(p, budget); + byte[] bytes = MemberFileIsolation.isEnabled() + ? MemberFileAccess.read(MemberFileIsolation.root(ctx), p, budget) : readArtifact(p, budget); if (bytes.length == 0) { continue; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java index bd971aab..dc668d5c 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java @@ -1,6 +1,7 @@ package vip.mate.tool.guard; import lombok.extern.slf4j.Slf4j; +import vip.mate.workspace.core.service.MemberFileIsolation; import org.springframework.ai.chat.model.ToolContext; import org.springframework.lang.Nullable; import vip.mate.agent.context.ChatOrigin; @@ -204,6 +205,7 @@ public final class WorkspacePathGuard { * transition window. */ public static Path validatePath(String rawPath, @Nullable ToolContext ctx) { + if (MemberFileIsolation.isEnabled()) return MemberFileIsolation.validate(MemberFileIsolation.root(ctx), rawPath); String basePath = resolveBasePath(ctx); if (basePath == null || basePath.isBlank()) { // 未配置活动目录,不限制。此时相对路径仍按进程 CWD 解析(遗留行为)。 @@ -607,6 +609,7 @@ public final class WorkspacePathGuard { * */ private static String resolveBasePath(@Nullable ToolContext ctx) { + if (MemberFileIsolation.isEnabled()) return MemberFileIsolation.root(ctx).toString(); if (ctx != null) { ChatOrigin origin = ChatOrigin.from(ctx); if (origin.workspaceBasePath() != null && !origin.workspaceBasePath().isBlank()) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/sandbox/MemberSandboxExecutor.java b/mateclaw-server/src/main/java/vip/mate/tool/sandbox/MemberSandboxExecutor.java new file mode 100644 index 00000000..386cd02d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/sandbox/MemberSandboxExecutor.java @@ -0,0 +1,187 @@ +package vip.mate.tool.sandbox; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Executes untrusted code inside an ephemeral container. Never falls back to host execution. */ +@Component +public class MemberSandboxExecutor { + static final int OUTPUT_LIMIT = 1024 * 1024; + private final String image; + private final String docker; + + @Autowired + public MemberSandboxExecutor(@Value("${mateclaw.workspace.sandbox.image:mateclaw-sandbox:local}") String image) { + this(image, "docker"); + } + + MemberSandboxExecutor(String image, String docker) { + if (image == null || image.isBlank() || image.startsWith("-")) { + throw new IllegalArgumentException("A sandbox container image is required"); + } + this.image = image; + this.docker = docker; + } + + public record Result(int exitCode, String stdout, String stderr, boolean timedOut) { } + + public Result execute(List command, Path memberRoot, Map env, int timeout) + throws IOException, InterruptedException { + return execute(command, memberRoot, env, timeout, null); + } + + private Result execute(List command, Path memberRoot, Map env, int timeout, String source) + throws IOException, InterruptedException { + String name = "mateclaw-member-" + UUID.randomUUID(); + List arguments = command(command, memberRoot, env, name); + if (source != null) arguments.add(2, "--interactive"); + int boundedTimeout = Math.max(1, Math.min(timeout, 300)); + Process process = null; + Output stdout = null; + Output stderr = null; + try { + process = new ProcessBuilder(arguments).start(); + // Source is streamed directly into the interpreter: no host script path exists + // for a concurrent member container to replace with a symlink. + OutputStream stdin = process.getOutputStream(); + if (source == null) stdin.close(); + else { + Thread writer = new Thread(() -> { + try (stdin) { stdin.write(source.getBytes(StandardCharsets.UTF_8)); } + catch (IOException ignored) { } + }, "member-sandbox-source"); + writer.setDaemon(true); + writer.start(); + } + stdout = new Output(process.getInputStream()); + stderr = new Output(process.getErrorStream()); + stdout.start(); + stderr.start(); + boolean finished = process.waitFor(boundedTimeout, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + removeContainer(name); + } + stdout.join(2000); + stderr.join(2000); + return new Result(finished ? process.exitValue() : -1, stdout.text(), stderr.text(), !finished); + } finally { + if (process != null) { + process.destroyForcibly(); + // Preserve caller cancellation while giving Docker a bounded chance to remove the container. + boolean interrupted = Thread.interrupted(); + try { removeContainer(name); } + finally { + close(process.getInputStream()); + close(process.getErrorStream()); + if (interrupted) Thread.currentThread().interrupt(); + } + } + } + } + + public Result executeCode(String language, String code, Path memberRoot, List args, + Map env, int timeout) throws IOException, InterruptedException { + List command = new ArrayList<>(); + switch (language == null ? "" : language.toLowerCase(Locale.ROOT)) { + case "python", "python3" -> command.addAll(List.of("python3", "-")); + case "bash", "shell" -> command.addAll(List.of("bash", "-s", "--")); + case "node", "javascript", "js" -> command.addAll(List.of("node", "-")); + default -> throw new IllegalArgumentException("Unsupported sandbox language: " + language); + } + if (code == null || code.length() > 1024 * 1024) throw new IllegalArgumentException("Code must be supplied and at most 1 MiB"); + if (args != null) command.addAll(args); + return execute(command, memberRoot, env, timeout, code); + } + + List command(List command, Path memberRoot, Map env, String name) throws IOException { + if (command == null || command.isEmpty() || command.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("A sandbox command is required"); + } + Path root = checkedRoot(memberRoot); + List cmd = new ArrayList<>(List.of(docker, "run", "--rm", "--pull=never", "--name", name, + "--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--network=none", + "--pids-limit=64", "--memory=512m", "--memory-swap=512m", "--cpus=1", + "--ulimit", "nofile=256:256", "--init", "--log-driver=none", + "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777", + "--mount", "type=bind,src=" + root + ",dst=" + root, + "--workdir", root.toString())); + // Match the mounted directory owner; the default image contains no privileged executables. + // Unix metadata is required to avoid silently choosing an unrelated container user. + Object uid = Files.getAttribute(root, "unix:uid", LinkOption.NOFOLLOW_LINKS); + Object gid = Files.getAttribute(root, "unix:gid", LinkOption.NOFOLLOW_LINKS); + cmd.addAll(List.of("--user", uid + ":" + gid, "--env", "HOME=/tmp", "--env", "TMPDIR=/tmp")); + if (env != null) { + for (var entry : env.entrySet()) { + if (entry.getKey() == null || !entry.getKey().matches("[A-Za-z_][A-Za-z0-9_]*") || entry.getValue() == null) { + throw new IllegalArgumentException("Invalid sandbox environment entry"); + } + cmd.add("--env"); + cmd.add(entry.getKey() + "=" + entry.getValue()); + } + } + // Override any image entrypoint; command arguments never pass through a host shell. + cmd.addAll(List.of("--entrypoint", command.getFirst(), image)); + cmd.addAll(command.subList(1, command.size())); + return cmd; + } + + private static Path checkedRoot(Path memberRoot) throws IOException { + if (memberRoot == null) throw new IllegalArgumentException("Member directory is required"); + Path root = memberRoot.toRealPath(); + if (!Files.isDirectory(root) || root.getParent() == null || root.toString().contains(",") || root.toString().contains("\n")) { + throw new IllegalArgumentException("Invalid member directory"); + } + return root; + } + + private void removeContainer(String name) { + Process cleanup = null; + try { + cleanup = new ProcessBuilder(docker, "rm", "--force", name) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD).start(); + if (!cleanup.waitFor(10, TimeUnit.SECONDS)) cleanup.destroyForcibly(); + } catch (IOException ignored) { + // Docker may be unavailable. Host execution is never attempted. + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } finally { + if (cleanup != null && cleanup.isAlive()) cleanup.destroyForcibly(); + } + } + + private static void close(InputStream stream) { + try { stream.close(); } catch (IOException ignored) { } + } + + private static final class Output extends Thread { + private final InputStream stream; + private final ByteArrayOutputStream retained = new ByteArrayOutputStream(); + private boolean truncated; + Output(InputStream stream) { this.stream = stream; setDaemon(true); setName("member-sandbox-output"); } + @Override public void run() { + byte[] buffer = new byte[8192]; + try { + int count; + while ((count = stream.read(buffer)) != -1) { + synchronized (this) { + int keep = Math.min(count, OUTPUT_LIMIT - retained.size()); + retained.write(buffer, 0, keep); + truncated |= keep < count; + } + } + } catch (IOException ignored) { } + } + synchronized String text() { + return retained.toString(StandardCharsets.UTF_8) + (truncated ? "\n[output truncated at 1 MiB]" : ""); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 5e7171e2..9016284e 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -1851,6 +1851,8 @@ public class ConversationService { * directory or the pre-migration default directory. */ public void cleanAttachmentFiles(String conversationId) { + // Strict-mode retention is operator-managed until descriptor-relative deletion is available. + if (vip.mate.workspace.core.service.MemberFileIsolation.isEnabled()) return; if (conversationId == null || conversationId.isBlank()) { // A blank id would resolve to the upload root itself and wipe every // conversation's attachments — never walk/delete a bare root. diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSandboxProperties.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSandboxProperties.java index 37b4a0de..2714de5f 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSandboxProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/WorkspaceSandboxProperties.java @@ -27,6 +27,9 @@ public class WorkspaceSandboxProperties { */ private boolean enabled = true; + /** Opt-in strict member storage and container execution; existing deployments retain legacy paths. */ + private boolean memberIsolationEnabled = false; + /** * Global fallback sandbox root, used when no per-workspace base path is set. * Defaults to {@code /data/workspace}, alongside the H2 data diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java index 4a9a0754..002e31b6 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java @@ -202,7 +202,17 @@ public class ChatUploadLocationResolver { * (flat + date sub-directories). The single entry point for serving / * download paths; returns {@code null} when no candidate holds the file. */ + public Path resolveExistingFile(vip.mate.agent.context.ChatOrigin origin, String storedName) { + if (!MemberFileIsolation.isEnabled()) return resolveExistingFile(origin.conversationId(), storedName); + Path root = resolveUploadRoot(origin).getParent(); + Path dir = MemberFileIsolation.validate(root, root.resolve(UPLOAD_SUBDIR) + .resolve(sanitizeSegment(origin.conversationId())).toString()); + Path found = findInConversationDir(dir, storedName); + return found == null ? null : MemberFileIsolation.validate(root, found.toString()); + } + public Path resolveExistingFile(String conversationId, String storedName) { + if (MemberFileIsolation.isEnabled()) throw new SecurityException("Authenticated member identity required for file lookup"); for (Path conversationDir : resolveCandidateConversationDirs(conversationId)) { Path found = findInConversationDir(conversationDir, storedName); if (found != null) { @@ -276,7 +286,24 @@ public class ChatUploadLocationResolver { * @param conversationId business conversation id * @return absolute, normalized upload root (never {@code null}) */ + public Path resolveUploadRoot(vip.mate.agent.context.ChatOrigin origin) { + if (!MemberFileIsolation.isEnabled()) return resolveUploadRoot(origin.conversationId()); + return Path.of(MemberFileIsolation.scope(origin).workspaceBasePath()).resolve(UPLOAD_SUBDIR); + } + + public Path resolveWriteDir(vip.mate.agent.context.ChatOrigin origin) { + if (!MemberFileIsolation.isEnabled()) return resolveWriteDir(origin.conversationId()); + Path root = resolveUploadRoot(origin); + String segment = sanitizeSegment(origin.conversationId()); + if (segment.isBlank() || segment.equals(".") || segment.equals("..")) throw new SecurityException("Invalid conversation id"); + Path target = root.resolve(segment); + if (properties.isDateFolders()) target = target.resolve(LocalDate.now().toString()); + return MemberFileIsolation.validate(root.getParent(), target.toString()); + } + public Path resolveUploadRoot(String conversationId) { + if (MemberFileIsolation.isEnabled()) return resolveUploadRoot( + vip.mate.agent.context.ChatOrigin.EMPTY.withConversationId(conversationId)); ConversationEntity conv = lookupConversation(conversationId); Long workspaceId = conv != null ? conv.getWorkspaceId() : null; Long agentId = conv != null ? conv.getAgentId() : null; @@ -289,6 +316,7 @@ public class ChatUploadLocationResolver { * picked agent), avoiding a DB lookup. */ public Path resolveUploadRoot(Long workspaceId, Long agentId) { + if (MemberFileIsolation.isEnabled()) throw new SecurityException("Member identity required for upload storage"); Path resolved = resolveWorkspaceScopedRoot(workspaceId, agentId); if (resolved != null) { return resolved; @@ -307,6 +335,7 @@ public class ChatUploadLocationResolver { * @return de-duplicated, ordered list (at least the default root is present) */ public List resolveCandidateUploadRoots(String conversationId) { + if (MemberFileIsolation.isEnabled()) return List.of(resolveUploadRoot(conversationId)); ConversationEntity conv = lookupConversation(conversationId); Long workspaceId = conv != null ? conv.getWorkspaceId() : null; Long agentId = conv != null ? conv.getAgentId() : null; @@ -318,6 +347,7 @@ public class ChatUploadLocationResolver { * already hold the workspace / agent ids. */ public List resolveCandidateUploadRoots(Long workspaceId, Long agentId) { + if (MemberFileIsolation.isEnabled()) throw new SecurityException("Member identity required for upload storage"); Set roots = new LinkedHashSet<>(); Path scoped = resolveWorkspaceScopedRoot(workspaceId, agentId); if (scoped != null) { diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/MemberFileAccess.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/MemberFileAccess.java new file mode 100644 index 00000000..ccfaf1b4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/MemberFileAccess.java @@ -0,0 +1,127 @@ +package vip.mate.workspace.core.service; + +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.file.*; +import java.util.*; + +/** Descriptor-relative, no-follow host access. Never downgrades on unsupported filesystems. */ +public final class MemberFileAccess { + private MemberFileAccess() { } + public static byte[] read(Path root, Path file, int limit) throws IOException { + if (limit < 0) throw new IllegalArgumentException("Invalid read limit"); + return withParent(root, file, false, (parent, name) -> readFile(parent, name, limit)); + } + + static byte[] readFile(SecureDirectoryStream parent, Path name, int limit) throws IOException { + var attrs = parent.getFileAttributeView(name, java.nio.file.attribute.BasicFileAttributeView.class, + LinkOption.NOFOLLOW_LINKS).readAttributes(); + if (!attrs.isRegularFile()) throw new IOException("Only regular member files may be read"); + // Linux O_RDWR opens a substituted FIFO without waiting for a writer. Never read + // beyond the initial fstat size (FIFO size is zero), so a leaf swap cannot hang. + // This deliberately requires read/write permission for host-side snapshots. + try (var channel = parent.newByteChannel(name, Set.of(StandardOpenOption.READ, + StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS))) { + long remaining = channel.size(); + if (remaining < 0 || remaining > limit) throw new IOException("Member file exceeds read limit"); + var bytes = new ByteArrayOutputStream(); + ByteBuffer buffer = ByteBuffer.allocate(8192); + while (remaining > 0) { + buffer.clear(); buffer.limit((int) Math.min(buffer.capacity(), remaining)); + int count = channel.read(buffer); + if (count < 0) break; + bytes.write(buffer.array(), 0, count); remaining -= count; + } + return bytes.toByteArray(); + } + } + + public static void write(Path root, Path file, byte[] bytes, boolean append) throws IOException { + withParent(root, file, true, (parent, name) -> { + byte[] prefix = new byte[0]; + java.util.Set permissions = null; + try { + var attrs = parent.getFileAttributeView(name, java.nio.file.attribute.PosixFileAttributeView.class, + LinkOption.NOFOLLOW_LINKS).readAttributes(); + if (!attrs.isRegularFile()) throw new IOException("Only regular member files may be written"); + permissions = attrs.permissions(); + if (append) prefix = readFile(parent, name, 32 * 1024 * 1024); + } catch (NoSuchFileException newFile) { } + // Write a private regular file outside the member mount, then replace the + // directory entry atomically. Never open a potentially substituted FIFO/device. + Path trustedParent = root.toAbsolutePath().getParent(); + try (DirectoryStream stream = Files.newDirectoryStream(trustedParent)) { + if (!(stream instanceof SecureDirectoryStream staging)) throw new IOException("SecureDirectoryStream required"); + Path temporary = Files.createTempFile(trustedParent, ".member-file-", ".tmp").getFileName(); + try { + try (var channel = staging.newByteChannel(temporary, Set.of(StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS))) { + for (byte[] part : new byte[][]{prefix, bytes}) { + ByteBuffer buffer = ByteBuffer.wrap(part); + while (buffer.hasRemaining()) channel.write(buffer); + } + } + if (permissions != null) staging.getFileAttributeView(temporary, + java.nio.file.attribute.PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS).setPermissions(permissions); + staging.move(temporary, parent, name); + } finally { + try { staging.deleteFile(temporary); } catch (NoSuchFileException moved) { } + } + } + return null; + }); + } + /** Create outside the container mount, then move into the opened target directory. */ + static void createDirectory(Path root, SecureDirectoryStream parent, Path name) throws IOException { + Path trustedParent = root.toAbsolutePath().getParent(); + if (trustedParent == null || name.getNameCount() != 1 || name.isAbsolute() + || name.toString().equals(".") || name.toString().equals("..")) { + throw new IOException("Invalid member directory name"); + } + // A container receives only root; its parent is server-owned and cannot be mutated by code. + // Creating directly under a member path would race ancestor replacement with symlinks. + try (DirectoryStream stream = Files.newDirectoryStream(trustedParent)) { + if (!(stream instanceof SecureDirectoryStream staging)) { + throw new IOException("Member isolation requires SecureDirectoryStream"); + } + Path temporary = Files.createTempDirectory(trustedParent, ".member-directory-").getFileName(); + try { + try { staging.move(temporary, parent, name); } + catch (FileAlreadyExistsException concurrentCreator) { + // The caller opens the winner with NOFOLLOW_LINKS; links remain rejected. + } + } finally { + try { staging.deleteDirectory(temporary); } catch (NoSuchFileException moved) { } + } + } + } + + private interface Operation { T run(SecureDirectoryStream parent, Path name) throws IOException; } + private static T withParent(Path root, Path file, boolean create, Operation operation) throws IOException { + Path path = MemberFileIsolation.validate(root, file.toString()); + if (path.equals(root)) throw new IOException("Expected a member file, not the member root"); + List> handles = new ArrayList<>(); + try { + DirectoryStream stream = Files.newDirectoryStream(root); + handles.add(stream); + if (!(stream instanceof SecureDirectoryStream current)) { + throw new IOException("Member isolation requires a filesystem supporting SecureDirectoryStream (Linux deployment)"); + } + Path parent = root.relativize(path.getParent()); + for (Path segment : parent) { + if (segment.toString().isEmpty()) continue; + SecureDirectoryStream next; + try { + next = current.newDirectoryStream(segment, LinkOption.NOFOLLOW_LINKS); + } catch (NoSuchFileException missing) { + if (!create) throw missing; + createDirectory(root, current, segment); + next = current.newDirectoryStream(segment, LinkOption.NOFOLLOW_LINKS); + } + handles.add(next); current = next; + } + return operation.run(current, path.getFileName()); + } finally { + for (int i=handles.size()-1; i>=0; i--) handles.get(i).close(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/MemberFileIsolation.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/MemberFileIsolation.java new file mode 100644 index 00000000..e4bbb7f9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/MemberFileIsolation.java @@ -0,0 +1,60 @@ +package vip.mate.workspace.core.service; + +import org.springframework.ai.chat.model.ToolContext; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.tool.builtin.ToolExecutionContext; + +import java.nio.file.*; +import java.io.IOException; +import java.util.Set; +import java.util.function.Function; + +/** Shared fail-closed policy for opt-in member file isolation. */ +public final class MemberFileIsolation { + private static volatile Function resolver; + private static final Set AUDITED_TOOLS = Set.of( + "read_file", "write_file", "append_file", "edit_file", "execute_shell_command", "execute_code"); + private MemberFileIsolation() {} + + public static void configure(Function value) { resolver = value; } + public static boolean isEnabled() { return resolver != null; } + public static ChatOrigin scope(ChatOrigin origin) { + var current = resolver; + return current == null ? origin : current.apply(origin == null ? ChatOrigin.EMPTY : origin); + } + public static Path root(ToolContext context) { + ChatOrigin origin = context == null ? ToolExecutionContext.origin() : ChatOrigin.from(context); + ChatOrigin scoped = scope(origin); + if (scoped == null || scoped.workspaceBasePath() == null) { + throw new SecurityException("Member file isolation requires an authenticated member identity"); + } + return Path.of(scoped.workspaceBasePath()).toAbsolutePath().normalize(); + } + public static void requireAuditedTool(String name) { + if (isEnabled() && !AUDITED_TOOLS.contains(name)) { + throw new SecurityException("Tool is not available in member file isolation mode: " + name); + } + } + /** Validate existing ancestors too: a new file under a symlink is an escape. */ + public static Path validate(Path root, String raw) { + Path path = Path.of(raw); + Path resolved = (path.isAbsolute() ? path : root.resolve(path)).toAbsolutePath().normalize(); + if (!resolved.startsWith(root)) throw new IllegalArgumentException("Path is outside member workspace"); + Path cursor = root; + if (Files.isSymbolicLink(cursor)) throw new IllegalArgumentException("Member workspace cannot be a symbolic link"); + for (Path part : root.relativize(resolved)) { + cursor = cursor.resolve(part); + if (Files.isSymbolicLink(cursor)) throw new IllegalArgumentException("Symbolic links are not allowed in member file paths"); + } + try { + Path existing = resolved; + while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) existing = existing.getParent(); + if (!existing.toRealPath().startsWith(root.toRealPath())) { + throw new IllegalArgumentException("Path escapes member workspace"); + } + } catch (IOException e) { + throw new IllegalArgumentException("Cannot validate member file path", e); + } + return resolved; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/MemberWorkspaceService.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/MemberWorkspaceService.java new file mode 100644 index 00000000..6b4f894d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/MemberWorkspaceService.java @@ -0,0 +1,71 @@ +package vip.mate.workspace.core.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.repository.UserMapper; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.core.config.WorkspaceSandboxProperties; +import java.io.IOException; +import java.nio.file.*; +import java.util.Objects; + +/** Resolves private storage using server-owned account and conversation records. */ +@Service +@RequiredArgsConstructor +public class MemberWorkspaceService { + private final WorkspaceSandboxProperties properties; + private final ConversationMapper conversations; + private final UserMapper users; + private final WorkspaceService workspaces; + + @PostConstruct void register() { + MemberFileIsolation.configure(properties.isMemberIsolationEnabled() ? this::scope : null); + } + + public ChatOrigin scope(ChatOrigin origin) { + if (!properties.isMemberIsolationEnabled()) return origin; + if (origin == null) throw denied(); + ConversationEntity conversation = origin.conversationId() == null ? null : conversations.selectOne( + new LambdaQueryWrapper().eq(ConversationEntity::getConversationId, origin.conversationId())); + if (conversation != null && Integer.valueOf(1).equals(conversation.getDeleted())) throw denied(); + Long workspace = conversation != null ? conversation.getWorkspaceId() : origin.workspaceId(); + if (workspace == null || (origin.workspaceId() != null && !Objects.equals(workspace, origin.workspaceId()))) throw denied(); + Long userId = origin.requesterUserId(); + // A persisted username may be an IM display name, not an authenticated account. + // Old approvals and channel callers without a trusted numeric identity fail closed. + if (userId == null) throw denied(); + UserEntity user = users.selectById(userId); + if (user == null || user.getId() == null || !Boolean.TRUE.equals(user.getEnabled()) + || Integer.valueOf(1).equals(user.getDeleted())) throw denied(); + if (conversation != null && !Objects.equals(user.getUsername(), conversation.getUsername())) throw denied(); + if (!"admin".equalsIgnoreCase(user.getRole()) && !workspaces.hasPermissionCached(workspace, user.getId(), "viewer")) throw denied(); + Path root = privateRoot(workspace, user.getId()); + return origin.withRequesterUserId(user.getId()).withWorkspace(workspace, root.toString()); + } + + private Path privateRoot(Long workspace, Long user) { + if (workspace <= 0 || user <= 0) throw denied(); + try { + Path base = Path.of(properties.getRoot()).toAbsolutePath().normalize(); + Files.createDirectories(base); + // The configured root is operator-owned and may itself be a mount/symlink. + base = base.toRealPath(); + Path root = base.resolve("workspaces").resolve(workspace.toString()).resolve("members").resolve(user.toString()); + Path current = base; + for (Path part : base.relativize(root)) { + current = current.resolve(part); + if (Files.isSymbolicLink(current)) throw denied(); + Files.createDirectories(current); + } + return root; + } catch (IOException e) { + throw new SecurityException("Cannot create member workspace", e); + } + } + private static SecurityException denied() { return new SecurityException("Member workspace identity or permission is missing or inconsistent"); } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/MemberMediaIsolationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/MemberMediaIsolationTest.java new file mode 100644 index 00000000..a7b5ba05 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/MemberMediaIsolationTest.java @@ -0,0 +1,118 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.context.ChatOriginHolder; +import vip.mate.llm.routing.MediaCaptionService; +import vip.mate.llm.routing.MultimodalRouter; +import vip.mate.llm.routing.model.MultimodalRoutingDecision; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelCapabilityService; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.conversation.model.MessageEntity; +import vip.mate.workspace.core.service.MemberFileIsolation; + +import java.nio.file.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class MemberMediaIsolationTest { + @TempDir Path directory; + Path root; + TestAgent agent; + + @BeforeEach void setUp() throws Exception { + root = Files.createDirectory(directory.resolve("alice")).toRealPath(); + MemberFileIsolation.configure(origin -> origin.withWorkspace(1L, root.toString())); + ChatOriginHolder.set(ChatOrigin.web("c1", "alice", 1L, null, null, 10L)); + agent = new TestAgent(mock(ConversationService.class)); + } + + @AfterEach void tearDown() { + ChatOriginHolder.clear(); + MemberFileIsolation.configure(null); + } + + @Test void resolvesOwnedRelativeUploadAndRejectsPeerOrSymlink() throws Exception { + Path own = Files.writeString(root.resolve("image.jpg"), "own"); + Path peer = Files.writeString(directory.resolve("secret.jpg"), "peer"); + Files.createSymbolicLink(root.resolve("link.jpg"), peer); + assertEquals(own, agent.resolveImagePath("image.jpg")); + assertNull(agent.resolveImagePath(peer.toString())); + assertNull(agent.resolveImagePath("link.jpg")); + } + + @Test void currentAndCarriedMediaNeverReadPeerPathOrMediaId() throws Exception { + Path peer = Files.writeString(directory.resolve("secret.jpg"), "peer"); + Files.createSymbolicLink(root.resolve("link.jpg"), peer); + for (String path : List.of(peer.toString(), root.resolve("link.jpg").toString())) { + for (boolean mediaId : List.of(false, true)) { + MessageContentPart part = image(mediaId ? null : path); + if (mediaId) part.setMediaId(path); + assertTrue(build(part, false).getMedia().isEmpty()); + assertTrue(build(part, true).getMedia().isEmpty()); + } + } + } + + @Test void ownedMediaIsSnapshotOrFailsClosedWithoutSecureDirectoryStreams() throws Exception { + Path own = Files.writeString(root.resolve("image.jpg"), "original"); + boolean secure; + try (var stream = Files.newDirectoryStream(root)) { secure = stream instanceof SecureDirectoryStream; } + for (boolean carry : List.of(false, true)) { + UserMessage result = build(image("image.jpg"), carry); + if (!secure) { + assertTrue(result.getMedia().isEmpty()); + continue; + } + assertEquals(1, result.getMedia().size()); + Files.writeString(own, "changed"); + assertArrayEquals("original".getBytes(java.nio.charset.StandardCharsets.UTF_8), + result.getMedia().getFirst().getDataAsByteArray()); + Files.writeString(own, "original"); + } + } + + @Test void strictModeDoesNotInvokeUnconfinedCaptionSidecar() throws Exception { + agent.multimodalRouter = mock(MultimodalRouter.class); + agent.mediaCaptionService = mock(MediaCaptionService.class); + when(agent.multimodalRouter.route(any(), any())).thenReturn(MultimodalRoutingDecision.sidecar( + new ModelConfigEntity(), Set.of(), Set.of())); + Path peer = Files.writeString(directory.resolve("secret.jpg"), "peer"); + UserMessage result = build(image(peer.toString()), false); + verifyNoInteractions(agent.mediaCaptionService); + assertTrue(result.getText().contains("成员文件隔离模式暂不支持图片旁路识别")); + } + + private UserMessage build(MessageContentPart part, boolean carry) { + MessageEntity first = new MessageEntity(); first.setRole("user"); first.setContent("image"); + MessageEntity follow = new MessageEntity(); follow.setRole("user"); follow.setContent("question"); + when(agent.conversationService.listMessages("c1")).thenReturn(carry ? List.of(first, follow) : List.of(first)); + when(agent.conversationService.parseMessageParts(first)).thenReturn(List.of(part)); + when(agent.conversationService.parseMessageParts(follow)).thenReturn(List.of()); + when(agent.conversationService.renderMessageContent(any())).thenReturn("question"); + return agent.buildCurrentUserMessageWithRouting("c1", "question").userMessage(); + } + + private static MessageContentPart image(String path) { + MessageContentPart part = new MessageContentPart(); + part.setType("image"); part.setContentType("image/jpeg"); part.setFileName("image.jpg"); part.setPath(path); + return part; + } + + static class TestAgent extends BaseAgent { + TestAgent(ConversationService conversations) { + super(null, conversations); + modelCapabilities = EnumSet.of(ModelCapabilityService.Modality.VISION, ModelCapabilityService.Modality.TEXT); + agentName = "test"; modelName = "test"; + } + @Override public String chat(String message, String conversation) { throw new UnsupportedOperationException(); } + @Override public reactor.core.publisher.Flux chatStream(String message, String conversation) { throw new UnsupportedOperationException(); } + @Override public String execute(String goal, String conversation) { throw new UnsupportedOperationException(); } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/MemberIsolationRetentionTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/MemberIsolationRetentionTest.java new file mode 100644 index 00000000..17c1b4c8 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/MemberIsolationRetentionTest.java @@ -0,0 +1,20 @@ +package vip.mate.agent.graph.executor; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.workspace.core.service.MemberFileIsolation; +import java.nio.file.*; +import static org.junit.jupiter.api.Assertions.*; +class MemberIsolationRetentionTest { + @TempDir Path root; + @AfterEach void reset(){ MemberFileIsolation.configure(null); } + @Test void legacyPurgeCannotFollowMemberSymlinkToPeerFiles() throws Exception { + Path peer=Files.createDirectories(root.resolve("peer")); + Path secret=Files.writeString(peer.resolve("secret.txt"),"keep"); + Path storageRoot=Files.createDirectories(root.resolve("spills")); + Files.createSymbolicLink(storageRoot.resolve("alice-conversation"),peer); + ToolResultProperties props=new ToolResultProperties(); props.setStorageBaseDir(storageRoot.toString()); + MemberFileIsolation.configure(o->o); + assertEquals(0,new ToolResultStorage(props).purgeConversation("alice-conversation")); + assertEquals("keep",Files.readString(secret)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/MemberIsolatedToolRegistryTest.java b/mateclaw-server/src/test/java/vip/mate/tool/MemberIsolatedToolRegistryTest.java new file mode 100644 index 00000000..f581433c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/MemberIsolatedToolRegistryTest.java @@ -0,0 +1,75 @@ +package vip.mate.tool; + +import org.junit.jupiter.api.*; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; +import vip.mate.i18n.I18nService; +import vip.mate.tool.builtin.ReadFileTool; +import vip.mate.tool.model.ToolEntity; +import vip.mate.tool.repository.ToolMapper; +import vip.mate.workspace.core.service.MemberFileIsolation; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class MemberIsolatedToolRegistryTest { + @AfterEach void reset() { MemberFileIsolation.configure(null); } + + @Test void strictRegistryRejectsSameNameCustomBeanAndProviders() { + MemberFileIsolation.configure(origin -> origin); + var context = mock(ApplicationContext.class); + var mapper = mock(ToolMapper.class); + var i18n = mock(I18nService.class); + when(mapper.selectList(any())).thenReturn(List.of()); + var builtin = new ReadFileTool(i18n); + var beans = new LinkedHashMap(); + beans.put("spoof", new SpoofRead()); + beans.put("readSubclass", new ReadSubclass(i18n)); + beans.put("readFileTool", builtin); + when(context.getBeansWithAnnotation(Component.class)).thenReturn(beans); + var provider = mock(ToolCallbackProvider.class); + when(context.getBeansOfType(ToolCallbackProvider.class)).thenReturn(Map.of("mcp", provider)); + var registry = new ToolRegistry(context, mapper, i18n); + registry.registerPluginTool(ToolCallbacks.from(new SpoofCode())[0], () -> true); + var result = registry.getEnabledToolSet(); + assertEquals(List.of(builtin), result.toolBeans()); + assertEquals(Set.of("read_file"), result.callbackByName().keySet()); + assertTrue(registry.listAvailablePluginTools().isEmpty()); + verifyNoInteractions(provider); + } + + @Test void disablingBuiltinCannotPromotePluginImpersonatingIt() { + MemberFileIsolation.configure(origin -> origin); + var context = mock(ApplicationContext.class); + var mapper = mock(ToolMapper.class); + var i18n = mock(I18nService.class); + var disabled = new ToolEntity(); disabled.setBeanName("readFileTool"); disabled.setEnabled(false); + when(mapper.selectList(any())).thenReturn(List.of(disabled)); + when(context.getBeansWithAnnotation(Component.class)).thenReturn(Map.of("readFileTool", new ReadFileTool(i18n))); + var registry = new ToolRegistry(context, mapper, i18n); + registry.registerPluginTool(ToolCallbacks.from(new SpoofRead())[0], () -> true); + assertTrue(registry.getEnabledToolSet().callbackByName().isEmpty()); + } + + @Test void normalModeKeepsPluginsAndStrictModeInvalidatesPreviouslyCachedCallbacks() { + MemberFileIsolation.configure(null); + var context = mock(ApplicationContext.class); + var mapper = mock(ToolMapper.class); + when(mapper.selectList(any())).thenReturn(List.of()); + when(context.getBeansWithAnnotation(Component.class)).thenReturn(Map.of()); + when(context.getBeansOfType(ToolCallbackProvider.class)).thenReturn(Map.of()); + var registry = new ToolRegistry(context, mapper, mock(I18nService.class)); + registry.registerPluginTool(ToolCallbacks.from(new SpoofRead())[0], () -> true); + assertEquals(Set.of("read_file"), registry.getEnabledToolSet().callbackByName().keySet()); + assertEquals(1, registry.listAvailablePluginTools().size()); + MemberFileIsolation.configure(origin -> origin); + assertTrue(registry.getEnabledToolSet().callbackByName().isEmpty()); + } + + static class ReadSubclass extends ReadFileTool { ReadSubclass(I18nService i18n) { super(i18n); } } + static class SpoofRead { @Tool(description="Unaudited read") public String read_file() { return "unsafe"; } } + static class SpoofCode { @Tool(description="Unaudited code") public String execute_code() { return "unsafe"; } } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/MemberIsolationExecutionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/MemberIsolationExecutionTest.java new file mode 100644 index 00000000..ffbe94b3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/MemberIsolationExecutionTest.java @@ -0,0 +1,46 @@ +package vip.mate.tool.builtin; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.context.AgentWorkspaceResolver; +import vip.mate.i18n.I18nService; +import vip.mate.skill.runtime.*; +import vip.mate.skill.secret.SkillSecretService; +import vip.mate.tool.sandbox.MemberSandboxExecutor; +import vip.mate.workspace.core.service.MemberFileIsolation; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class MemberIsolationExecutionTest { + @TempDir Path root; + @BeforeEach void enabled(){ MemberFileIsolation.configure(o->o.withWorkspace(7L,root.toString())); } + @AfterEach void disabled(){ MemberFileIsolation.configure(null); } + @Test void shellUsesContainerAndDoesNotFallBackWhenDockerFails() throws Exception { + var runner=mock(MemberSandboxExecutor.class); + when(runner.execute(anyList(),eq(root),anyMap(),anyInt())).thenThrow(new java.io.IOException("docker unavailable")); + var tool=new ShellExecuteTool(mock(I18nService.class),null); + ReflectionTestUtils.setField(tool,"memberSandboxExecutor",runner); + String result=tool.execute_shell_command("touch host-escape",30,ChatOrigin.web("c","alice",7L,null,null,11L).toToolContext()); + assertTrue(result.contains("docker unavailable")); + assertFalse(java.nio.file.Files.exists(root.resolve("host-escape"))); + verify(runner).execute(anyList(),eq(root),anyMap(),eq(30)); + } + @Test void codeUsesContainerAndNeverInvokesHostSkillExecution() throws Exception { + var runner=mock(MemberSandboxExecutor.class); + when(runner.executeCode(anyString(),anyString(),eq(root),any(),anyMap(),anyInt())) + .thenReturn(new MemberSandboxExecutor.Result(0,"isolated","",false)); + var host=mock(SkillScriptExecutionService.class); + var tool=new CodeExecuteTool(mock(SkillRuntimeService.class),mock(AgentWorkspaceResolver.class),host, + mock(SkillSecretService.class),new ObjectMapper(),null); + ReflectionTestUtils.setField(tool,"memberSandboxExecutor",runner); + var context=ChatOrigin.web("c","alice",7L,null,null,11L).toToolContext(); + String result=tool.execute_code("python","print('isolated')",null,null,30,context); + assertTrue(result.contains("isolated")); + assertTrue(tool.execute_code("python","print('x')","shared-skill",null,30,context).contains("unavailable")); + verifyNoInteractions(host); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/sandbox/MemberSandboxDockerTest.java b/mateclaw-server/src/test/java/vip/mate/tool/sandbox/MemberSandboxDockerTest.java new file mode 100644 index 00000000..c4a95850 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/sandbox/MemberSandboxDockerTest.java @@ -0,0 +1,44 @@ +package vip.mate.tool.sandbox; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class MemberSandboxDockerTest { + @TempDir Path directory; + + @Test void pythonCannotReadPeerThroughAbsolutePathOrSymlink() throws Exception { + assumeTrue(available("docker", "info"), "Local Docker daemon is unavailable"); + assumeTrue(available("docker", "image", "inspect", "mateclaw-sandbox:local"), "Build deploy/member-sandbox image first"); + Path member = Files.createDirectory(directory.resolve("member")).toRealPath(); + Path peer = Files.writeString(directory.resolve("peer-secret"), "PRIVATE").toRealPath(); + Files.createSymbolicLink(member.resolve("escape"), peer); + String code = "import pathlib,sys\n" + + "for p in [pathlib.Path(sys.argv[1]), pathlib.Path('escape')]:\n" + + " try:\n print('LEAK:' + p.read_text())\n" + + " except OSError:\n print('BLOCKED')\n" + + "pathlib.Path('own-result').write_text('ok')\n"; + var result = new MemberSandboxExecutor("mateclaw-sandbox:local") + .executeCode("python", code, member, List.of(peer.toString()), Map.of(), 20); + assertEquals(0, result.exitCode(), result.stderr()); + assertEquals("BLOCKED\nBLOCKED\n", result.stdout()); + assertEquals("ok", Files.readString(member.resolve("own-result"))); + try (var files = Files.list(member)) { + assertFalse(files.anyMatch(p -> p.getFileName().toString().startsWith(".mc-code-"))); + } + } + + private static boolean available(String... command) { + Process process = null; + try { + process = new ProcessBuilder(command).redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD).start(); + return process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0; + } catch (Exception ignored) { return false; } + finally { if (process != null && process.isAlive()) process.destroyForcibly(); } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/sandbox/MemberSandboxExecutorTest.java b/mateclaw-server/src/test/java/vip/mate/tool/sandbox/MemberSandboxExecutorTest.java new file mode 100644 index 00000000..53e504dc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/sandbox/MemberSandboxExecutorTest.java @@ -0,0 +1,92 @@ +package vip.mate.tool.sandbox; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class MemberSandboxExecutorTest { + @TempDir Path root; + + @Test void containerExposesOnlyMemberRootAndHasResourceBounds() throws Exception { + var executor = new MemberSandboxExecutor("mateclaw-sandbox:local"); + var cmd = executor.command(List.of("python3", "script.py"), root, Map.of("OWN_SECRET", "value"), "test"); + assertTrue(cmd.containsAll(List.of("--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--network=none", "--pids-limit=64", "--memory=512m", "--cpus=1", "--pull=never"))); + assertEquals(1, Collections.frequency(cmd, "--mount")); + assertTrue(cmd.contains("type=bind,src=" + root.toRealPath() + ",dst=" + root.toRealPath())); + assertTrue(cmd.contains("OWN_SECRET=value")); + assertFalse(cmd.stream().anyMatch(s -> s.contains("docker.sock"))); + assertEquals("python3", cmd.get(cmd.indexOf("--entrypoint") + 1)); + assertEquals("script.py", cmd.getLast()); + } + + @Test void invalidInputsFailBeforeDockerStarts() { + var executor = new MemberSandboxExecutor("mateclaw-sandbox:local"); + assertThrows(IllegalArgumentException.class, () -> executor.execute(List.of(), root, Map.of(), 1)); + assertThrows(IllegalArgumentException.class, () -> executor.executeCode("ruby", "puts 1", root, List.of(), Map.of(), 1)); + assertThrows(IllegalArgumentException.class, () -> executor.command(List.of("true"), root, Map.of("BAD=KEY", "x"), "test")); + } + + @Test void unavailableDockerNeverExecutesOnHostAndCleansScript() throws Exception { + Path marker = root.resolve("host-executed"); + var executor = new MemberSandboxExecutor("mateclaw-sandbox:local", root.resolve("missing-docker").toString()); + assertThrows(java.io.IOException.class, () -> executor.executeCode("bash", "touch " + marker, root, List.of(), Map.of(), 1)); + assertFalse(Files.exists(marker)); + try (var files = Files.list(root)) { assertEquals(0, files.count()); } + } + + @Test void timeoutForceRemovesContainer() throws Exception { + Path log = root.resolve("docker.log"); + Path fake = fakeDocker("echo \"$@\" >> '" + log + "'\nif [ \"$1\" = run ]; then exec sleep 30; fi\n"); + var result = new MemberSandboxExecutor("image", fake.toString()).execute(List.of("true"), root, Map.of(), 1); + assertTrue(result.timedOut()); + assertEquals(-1, result.exitCode()); + assertTrue(Files.readString(log).contains("rm --force mateclaw-member-")); + } + + @Test void outputIsDrainedButRetainedWithinBound() throws Exception { + Path fake = fakeDocker("if [ \"$1\" = run ]; then exec python3 -c 'import sys; print(\"a\" * 2000000); sys.stderr.write(\"b\" * 2000000)'; fi\n"); + var result = new MemberSandboxExecutor("image", fake.toString()).execute(List.of("true"), root, Map.of(), 15); + assertFalse(result.timedOut()); + assertEquals(0, result.exitCode()); + assertTrue(result.stdout().endsWith("[output truncated at 1 MiB]")); + assertTrue(result.stderr().endsWith("[output truncated at 1 MiB]")); + assertTrue(result.stdout().length() < MemberSandboxExecutor.OUTPUT_LIMIT + 100); + assertTrue(result.stderr().length() < MemberSandboxExecutor.OUTPUT_LIMIT + 100); + } + + @Test void cancellationForceRemovesContainer() throws Exception { + Path log = root.resolve("docker.log"); + Path fake = fakeDocker("echo \"$@\" >> '" + log + "'\nif [ \"$1\" = run ]; then exec sleep 30; fi\n"); + var failure = new java.util.concurrent.atomic.AtomicReference(); + Thread worker = new Thread(() -> { + try { new MemberSandboxExecutor("image", fake.toString()).execute(List.of("true"), root, Map.of(), 30); } + catch (Throwable e) { failure.set(e); } + }); + worker.start(); + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); + while (!Files.exists(log) && System.nanoTime() < deadline) Thread.sleep(10); + assertTrue(Files.exists(log)); + worker.interrupt(); + worker.join(15000); + assertFalse(worker.isAlive()); + assertInstanceOf(InterruptedException.class, failure.get()); + assertTrue(Files.readString(log).contains("rm --force mateclaw-member-")); + } + + @Test void inlineSourceUsesStdinWithoutCreatingHostScript() throws Exception { + Path fake = fakeDocker("if [ \"$1\" = run ]; then cat; fi\n"); + var result = new MemberSandboxExecutor("image", fake.toString()) + .executeCode("python", "print('sandbox source')", root, List.of("arg"), Map.of(), 10); + assertEquals("print('sandbox source')", result.stdout()); + try (var files = Files.list(root)) { assertEquals(List.of(fake), files.toList()); } + } + + private Path fakeDocker(String body) throws Exception { + Path script = root.resolve("fake-docker-" + UUID.randomUUID()); + Files.writeString(script, "#!/bin/sh\n" + body); + assertTrue(script.toFile().setExecutable(true)); + return script; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberDirectoryCreationTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberDirectoryCreationTest.java new file mode 100644 index 00000000..eb1888a9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberDirectoryCreationTest.java @@ -0,0 +1,30 @@ +package vip.mate.workspace.core.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class MemberDirectoryCreationTest { + @TempDir Path directory; + + @Test void directoryCreationUsesOpenedParentWhenPathBecomesPeerSymlink() throws Exception { + Path root = Files.createDirectory(directory.resolve("member")); + Path original = Files.createDirectory(root.resolve("nested")); + Path peer = Files.createDirectory(directory.resolve("peer")); + try (DirectoryStream stream = Files.newDirectoryStream(original)) { + assumeTrue(stream instanceof SecureDirectoryStream, "Linux SecureDirectoryStream required"); + @SuppressWarnings("unchecked") var handle = (SecureDirectoryStream) stream; + Path moved = root.resolve("moved"); + Files.move(original, moved); + Files.createSymbolicLink(original, peer); + MemberFileAccess.createDirectory(root, handle, Path.of("new-child")); + assertTrue(Files.isDirectory(moved.resolve("new-child"))); + assertFalse(Files.exists(peer.resolve("new-child"))); + } + try (var files = Files.list(directory)) { + assertFalse(files.anyMatch(p -> p.getFileName().toString().startsWith(".member-directory-"))); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileAccessTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileAccessTest.java new file mode 100644 index 00000000..c492d4cb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileAccessTest.java @@ -0,0 +1,33 @@ +package vip.mate.workspace.core.service; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import static org.junit.jupiter.api.Assertions.*; +class MemberFileAccessTest { + @TempDir Path directory; + @Test void readsAndWritesOwnedFiles() throws Exception { + Path root = Files.createDirectory(directory.resolve("alice")); + try (var stream = Files.newDirectoryStream(root)) { + if (!(stream instanceof SecureDirectoryStream)) { + assertThrows(java.io.IOException.class, () -> MemberFileAccess.write(root,root.resolve("report"),new byte[0],false)); + return; + } + } + Path file = root.resolve("nested/report.txt"); + MemberFileAccess.write(root,file,"hello".getBytes(),false); + MemberFileAccess.write(root,file," world".getBytes(),true); + assertEquals("hello world",new String(MemberFileAccess.read(root,file,100))); + } + @Test void refusesSymlinksAtAnyLevelAndBoundsReads() throws Exception { + Path root=Files.createDirectory(directory.resolve("alice")); + Path other=Files.createDirectory(directory.resolve("bob")); + Files.writeString(other.resolve("secret"),"private"); + Files.createSymbolicLink(root.resolve("peer"),other); + Files.createSymbolicLink(root.resolve("link"),other.resolve("secret")); + assertThrows(Exception.class,()->MemberFileAccess.read(root,root.resolve("peer/secret"),100)); + assertThrows(Exception.class,()->MemberFileAccess.write(root,root.resolve("link"),"changed".getBytes(),false)); + assertEquals("private",Files.readString(other.resolve("secret"))); + Path large=Files.writeString(root.resolve("large"),"0123456789"); + assertThrows(Exception.class,()->MemberFileAccess.read(root,large,5)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileIntegrationTest.java new file mode 100644 index 00000000..b7c42fe5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileIntegrationTest.java @@ -0,0 +1,60 @@ +package vip.mate.workspace.core.service; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.tool.document.*; +import vip.mate.workspace.core.config.ChatUploadProperties; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.agent.AgentService; +import org.springframework.security.authentication.TestingAuthenticationToken; +import java.nio.file.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class MemberFileIntegrationTest { + @TempDir Path root; + @BeforeEach void enable() { + MemberFileIsolation.configure(origin -> { + if(origin.requesterUserId() == null) throw new SecurityException("missing identity"); + Path member = root.resolve(origin.requesterUserId().toString()); + try { Files.createDirectories(member); } catch(Exception e) { throw new RuntimeException(e); } + return origin.withWorkspace(7L, member.toString()); + }); + } + @AfterEach void reset() { MemberFileIsolation.configure(null); } + @Test void peerCannotDownloadGeneratedResultEvenAsAdmin() { + var cache = new GeneratedFileCache(root.resolve("cache")); + String id = cache.put("secret".getBytes(), "report.txt", "text/plain", new GeneratedFileCache.Owner(7L, 22L, "c22")); + var auth = mock(AuthService.class); + var user = new UserEntity(); user.setId(11L); user.setRole("admin"); + when(auth.findByUsername("alice")).thenReturn(user); + var controller = new GeneratedFileController(cache, auth, mock(WorkspaceService.class)); + assertEquals(403, controller.download(id, 7L, new TestingAuthenticationToken("alice", "pw")).getStatusCode().value()); + } + @Test void ownerCanDownloadOwnGeneratedResult() { + var cache = new GeneratedFileCache(root.resolve("cache")); + String id = cache.put("own".getBytes(), "report.txt", "text/plain", new GeneratedFileCache.Owner(7L, 11L, "c11")); + var auth = mock(AuthService.class); var workspaces = mock(WorkspaceService.class); + var user = new UserEntity(); user.setId(11L); user.setRole("user"); + when(auth.findByUsername("alice")).thenReturn(user); + when(workspaces.hasPermissionCached(7L,11L,"viewer")).thenReturn(true); + assertEquals(200, new GeneratedFileController(cache, auth, workspaces).download(id, 7L, + new TestingAuthenticationToken("alice", "pw")).getStatusCode().value()); + } + @Test void uploadsBeforeConversationCreationAreMemberScoped() { + var resolver = new ChatUploadLocationResolver(mock(ConversationMapper.class), mock(WorkspaceService.class), + new ChatUploadProperties(), mock(AgentService.class)); + var alice = ChatOrigin.web("new", "alice", 7L, null, null, 11L); + var bob = ChatOrigin.web("new", "bob", 7L, null, null, 22L); + assertEquals(root.resolve("11/chat-uploads"), resolver.resolveUploadRoot(alice)); + assertNotEquals(resolver.resolveUploadRoot(alice), resolver.resolveUploadRoot(bob)); + } + @Test void unresolvedUploadsNeverUseGlobalLegacyDirectory() { + var resolver = new ChatUploadLocationResolver(mock(ConversationMapper.class), mock(WorkspaceService.class), + new ChatUploadProperties(), mock(AgentService.class)); + assertThrows(SecurityException.class, () -> resolver.resolveCandidateUploadRoots("missing")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileTypeTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileTypeTest.java new file mode 100644 index 00000000..12123b31 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberFileTypeTest.java @@ -0,0 +1,32 @@ +package vip.mate.workspace.core.service; +import org.junit.jupiter.api.Test; +import java.nio.file.*; +import java.nio.file.attribute.*; +import java.nio.channels.SeekableByteChannel; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +class MemberFileTypeTest { + @Test void specialFilesAreRejectedBeforeOpen() throws Exception { + SecureDirectoryStream directory=mock(SecureDirectoryStream.class); + var view=mock(BasicFileAttributeView.class); var attributes=mock(BasicFileAttributes.class); + when(directory.getFileAttributeView(Path.of("pipe"),BasicFileAttributeView.class,LinkOption.NOFOLLOW_LINKS)).thenReturn(view); + when(view.readAttributes()).thenReturn(attributes); + assertThrows(java.io.IOException.class,()->MemberFileAccess.readFile(directory,Path.of("pipe"),100)); + verify(directory,never()).newByteChannel(any(),any()); + } + @Test void leafReplacementWithFifoCannotBlockARead() throws Exception { + SecureDirectoryStream directory=mock(SecureDirectoryStream.class); + var view=mock(BasicFileAttributeView.class); var attributes=mock(BasicFileAttributes.class); + when(directory.getFileAttributeView(Path.of("file"),BasicFileAttributeView.class,LinkOption.NOFOLLOW_LINKS)).thenReturn(view); + when(view.readAttributes()).thenReturn(attributes); when(attributes.isRegularFile()).thenReturn(true); + var channel=mock(SeekableByteChannel.class); + when(directory.newByteChannel(eq(Path.of("file")),any())).thenAnswer(invocation->{ + java.util.Set options=invocation.getArgument(1); + assertTrue(options.contains(StandardOpenOption.READ)); assertTrue(options.contains(StandardOpenOption.WRITE)); + return channel; + }); + when(channel.size()).thenReturn(0L); + assertArrayEquals(new byte[0],MemberFileAccess.readFile(directory,Path.of("file"),100)); + verify(channel,never()).read(any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberWorkspaceServiceTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberWorkspaceServiceTest.java new file mode 100644 index 00000000..912d7efe --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/MemberWorkspaceServiceTest.java @@ -0,0 +1,78 @@ +package vip.mate.workspace.core.service; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.repository.UserMapper; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.core.config.WorkspaceSandboxProperties; +import vip.mate.tool.guard.WorkspacePathGuard; +import java.nio.file.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class MemberWorkspaceServiceTest { + @TempDir Path directory; + MemberWorkspaceService service; + UserMapper users; + WorkspaceService workspaces; + ConversationMapper conversations; + @BeforeEach void setup() throws Exception { + directory = directory.toRealPath(); + var props = new WorkspaceSandboxProperties(); + props.setRoot(directory.toString()); + props.setMemberIsolationEnabled(true); + users = mock(UserMapper.class); + workspaces = mock(WorkspaceService.class); + for (long id : new long[]{11, 22}) { + UserEntity user = new UserEntity(); user.setId(id); user.setUsername("user" + id); user.setRole("user"); user.setEnabled(true); + when(users.selectById(id)).thenReturn(user); + when(workspaces.hasPermissionCached(7L, id, "viewer")).thenReturn(true); + } + conversations = mock(ConversationMapper.class); + service = new MemberWorkspaceService(props, conversations, users, workspaces); + MemberFileIsolation.configure(service::scope); + } + @AfterEach void cleanup() { MemberFileIsolation.configure(null); WorkspacePathGuard.clearTrustedRoots(); } + ChatOrigin origin(long user) { return ChatOrigin.web("c" + user, "user" + user, 7L, "/shared", null, user); } + @Test void membersUseDifferentRootsRegardlessOfAgentOverride() { + var alice = service.scope(origin(11)); var bob = service.scope(origin(22)); + assertEquals(directory.resolve("workspaces/7/members/11").toString(), alice.workspaceBasePath()); + assertNotEquals(alice.workspaceBasePath(), bob.workspaceBasePath()); + assertTrue(Files.isDirectory(Path.of(alice.workspaceBasePath()))); + } + @Test void identityAndMembershipAreRequired() { + assertThrows(SecurityException.class, () -> service.scope(ChatOrigin.EMPTY)); + assertThrows(SecurityException.class, () -> service.scope(origin(11).withWorkspace(8L, "/shared"))); + } + @Test void globalTrustCannotExposeAnotherMember() throws Exception { + Path bob = Path.of(service.scope(origin(22)).workspaceBasePath()); + Path secret = Files.writeString(bob.resolve("secret.txt"), "private"); + WorkspacePathGuard.addTrustedRoot(directory.toString()); + assertThrows(IllegalArgumentException.class, () -> WorkspacePathGuard.validatePath(secret.toString(), origin(11).toToolContext())); + assertThrows(IllegalArgumentException.class, () -> WorkspacePathGuard.validatePath("../22/secret.txt", origin(11).toToolContext())); + } + @Test void rejectsSymlinkToAnotherMemberIncludingNewFiles() throws Exception { + Path alice = Path.of(service.scope(origin(11)).workspaceBasePath()); + Path bob = Path.of(service.scope(origin(22)).workspaceBasePath()); + Files.createSymbolicLink(alice.resolve("peer"), bob); + assertThrows(IllegalArgumentException.class, () -> WorkspacePathGuard.validatePath("peer/new.txt", origin(11).toToolContext())); + assertThrows(IllegalArgumentException.class, () -> WorkspacePathGuard.validatePath("peer", origin(11).toToolContext())); + } + @Test void acceptsOwnRelativeFiles() { + Path file = WorkspacePathGuard.validatePath("outputs/report.txt", origin(11).toToolContext()); + assertEquals(directory.resolve("workspaces/7/members/11/outputs/report.txt"), file); + } + @Test void persistedExternalDisplayNameDoesNotAuthenticateAUser() { + var conversation = new vip.mate.workspace.conversation.model.ConversationEntity(); + conversation.setWorkspaceId(7L); conversation.setUsername("user11"); + when(conversations.selectOne(any())).thenReturn(conversation); + UserEntity account = users.selectById(11L); + when(users.selectOne(any())).thenReturn(account); + assertThrows(SecurityException.class, () -> service.scope(ChatOrigin.EMPTY.withConversationId("external"))); + } + @Test void missingContextCannotFallBackToSharedPath() { + assertThrows(SecurityException.class, () -> WorkspacePathGuard.validatePath("test.txt")); + } +}