feat(workspace): add opt-in member file isolation (#644)

This commit is contained in:
taobig 2026-09-22 23:13:08 -04:00
parent 3fbe5a18a1
commit baeb3f982a
40 changed files with 1411 additions and 39 deletions

View File

@ -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<String> 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)) {

View File

@ -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,

View File

@ -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<String, Object> 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<Message> 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.

View File

@ -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<DirectToolOutput> 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<String, Object> 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(); }
});
}
// ==================== 内部数据类 ====================

View File

@ -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;
}

View File

@ -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<String, Object> 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<Message> 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.

View File

@ -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<Process> processRef,
AtomicReference<RuntimeContextUsage> 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.<AgentService.StreamDelta>create(sink -> {
Process process = null;
try {

View File

@ -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);

View File

@ -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<ChatUploadResponse> upload(
public R<ChatUploadResponse> 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<ChatUploadResponse> upload(String conversationId, MultipartFile file, Authentication auth) throws IOException {
return uploadForWorkspace(conversationId, file, null, auth);
}
private R<ChatUploadResponse> 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();
}

View File

@ -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<String> args, Map<String, String> 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<String> args, Map<String, String> 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);
}

View File

@ -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<PluginToolEntry> 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<Class<?>> 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<Boolean> availabilityCheck) {}
@ -83,6 +95,7 @@ public class ToolRegistry {
* through the same {@code mate_agent_tool.tool_name} path as other tools.
*/
public List<ToolCallback> listAvailablePluginTools() {
if (MemberFileIsolation.isEnabled()) return List.of();
List<ToolCallback> out = new ArrayList<>();
for (PluginToolEntry entry : pluginTools) {
try {
@ -174,6 +187,9 @@ public class ToolRegistry {
for (Map.Entry<String, Object> 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<String, ToolCallbackProvider> providerBeans = applicationContext.getBeansOfType(ToolCallbackProvider.class);
Map<String, ToolCallbackProvider> providerBeans = MemberFileIsolation.isEnabled()
? Map.of() : applicationContext.getBeansOfType(ToolCallbackProvider.class);
List<ToolCallbackProvider> 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.<PluginToolEntry>of() : pluginTools) {
try {
if (Boolean.TRUE.equals(entry.availabilityCheck().get())) {
localizedCallbacks.add(entry.callback());

View File

@ -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);

View File

@ -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<Path> candidateUploadDirs(String conversationId) {
Set<Path> 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);

View File

@ -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<String, String> 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<String> normalizeArgs(String args) {
if (args == null) {
return null;

View File

@ -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);

View File

@ -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<String> allLines = readLinesUtf8(path);
List<String> 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);

View File

@ -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<String> 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) {

View File

@ -20,6 +20,8 @@ import vip.mate.agent.context.ChatOrigin;
*/
public final class ToolExecutionContext {
private static final ThreadLocal<ChatOrigin> ORIGIN = new ThreadLocal<>();
private static final ThreadLocal<String> CONVERSATION_ID = new ThreadLocal<>();
private static final ThreadLocal<String> 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();

View File

@ -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);

View File

@ -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"));
}

View File

@ -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;
}

View File

@ -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 {
* </ol>
*/
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()) {

View File

@ -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<String> command, Path memberRoot, Map<String, String> env, int timeout)
throws IOException, InterruptedException {
return execute(command, memberRoot, env, timeout, null);
}
private Result execute(List<String> command, Path memberRoot, Map<String, String> env, int timeout, String source)
throws IOException, InterruptedException {
String name = "mateclaw-member-" + UUID.randomUUID();
List<String> 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<String> args,
Map<String, String> env, int timeout) throws IOException, InterruptedException {
List<String> 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<String> command(List<String> command, Path memberRoot, Map<String, String> 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<String> 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]" : "");
}
}
}

View File

@ -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.

View File

@ -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 <working dir>/data/workspace}, alongside the H2 data

View File

@ -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<Path> 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<Path> resolveCandidateUploadRoots(Long workspaceId, Long agentId) {
if (MemberFileIsolation.isEnabled()) throw new SecurityException("Member identity required for upload storage");
Set<Path> roots = new LinkedHashSet<>();
Path scoped = resolveWorkspaceScopedRoot(workspaceId, agentId);
if (scoped != null) {

View File

@ -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<Path> 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<java.nio.file.attribute.PosixFilePermission> 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<Path> stream = Files.newDirectoryStream(trustedParent)) {
if (!(stream instanceof SecureDirectoryStream<Path> 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<Path> 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<Path> stream = Files.newDirectoryStream(trustedParent)) {
if (!(stream instanceof SecureDirectoryStream<Path> 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> { T run(SecureDirectoryStream<Path> parent, Path name) throws IOException; }
private static <T> T withParent(Path root, Path file, boolean create, Operation<T> 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<DirectoryStream<Path>> handles = new ArrayList<>();
try {
DirectoryStream<Path> stream = Files.newDirectoryStream(root);
handles.add(stream);
if (!(stream instanceof SecureDirectoryStream<Path> 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<Path> 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();
}
}
}

View File

@ -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<ChatOrigin, ChatOrigin> resolver;
private static final Set<String> AUDITED_TOOLS = Set.of(
"read_file", "write_file", "append_file", "edit_file", "execute_shell_command", "execute_code");
private MemberFileIsolation() {}
public static void configure(Function<ChatOrigin, ChatOrigin> 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;
}
}

View File

@ -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<ConversationEntity>().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"); }
}

View File

@ -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<String> chatStream(String message, String conversation) { throw new UnsupportedOperationException(); }
@Override public String execute(String goal, String conversation) { throw new UnsupportedOperationException(); }
}
}

View File

@ -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));
}
}

View File

@ -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<String,Object>();
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"; } }
}

View File

@ -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);
}
}

View File

@ -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(); }
}
}

View File

@ -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<Throwable>();
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;
}
}

View File

@ -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<Path> stream = Files.newDirectoryStream(original)) {
assumeTrue(stream instanceof SecureDirectoryStream<?>, "Linux SecureDirectoryStream required");
@SuppressWarnings("unchecked") var handle = (SecureDirectoryStream<Path>) 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-")));
}
}
}

View File

@ -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));
}
}

View File

@ -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"));
}
}

View File

@ -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<Path> 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<Path> 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());
}
}

View File

@ -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"));
}
}