mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-02 22:16:58 +08:00
feat(workspace): add opt-in member file isolation (#644)
This commit is contained in:
parent
3fbe5a18a1
commit
baeb3f982a
@ -9,6 +9,8 @@ import org.springframework.ai.chat.messages.SystemMessage;
|
|||||||
import org.springframework.ai.chat.messages.UserMessage;
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
import org.springframework.ai.content.Media;
|
import org.springframework.ai.content.Media;
|
||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.util.MimeType;
|
import org.springframework.util.MimeType;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
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.MessageMetadataJson;
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
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.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@ -980,6 +984,12 @@ public abstract class BaseAgent {
|
|||||||
: MultimodalRoutingDecision.none();
|
: MultimodalRoutingDecision.none();
|
||||||
|
|
||||||
StringBuilder textBuilder = new StringBuilder(renderedContent == null ? "" : renderedContent);
|
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<>();
|
java.util.Set<String> sidecarHandledIdentifiers = new java.util.HashSet<>();
|
||||||
if (decision.strategy() == MultimodalRoutingDecision.Strategy.SIDECAR
|
if (decision.strategy() == MultimodalRoutingDecision.Strategy.SIDECAR
|
||||||
&& mediaCaptionService != null
|
&& mediaCaptionService != null
|
||||||
@ -1105,7 +1115,7 @@ public abstract class BaseAgent {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
MimeType mimeType = MimeType.valueOf(contentType);
|
MimeType mimeType = MimeType.valueOf(contentType);
|
||||||
Media media = new Media(mimeType, new FileSystemResource(mediaPath));
|
Media media = new Media(mimeType, mediaResource(mediaPath));
|
||||||
mediaList.add(media);
|
mediaList.add(media);
|
||||||
log.debug("[{}] Injected {} into prompt: {} ({})",
|
log.debug("[{}] Injected {} into prompt: {} ({})",
|
||||||
agentName, isVideo ? "video" : "image", part.getFileName(), mediaPath);
|
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
|
// either. With a media tool the LLM may legitimately choose to
|
||||||
// delegate to the tool — never instruct it not to use tools.
|
// delegate to the tool — never instruct it not to use tools.
|
||||||
if (!hasMediaCapableTools()) {
|
if (!hasMediaCapableTools()) {
|
||||||
textBuilder.append("\n请用对话语言清晰、友好地告诉用户:当前模型无法处理这类附件,建议切换到具备相应能力的多模态模型,或在「设置 → 模型」中配置视觉/视频模型作为旁路。");
|
textBuilder.append(MemberFileIsolation.isEnabled()
|
||||||
|
? "\n请明确告知用户附件未能加载;成员文件隔离模式仅支持读取本人的文件,请使用原生支持此类附件的多模态模型,旁路识别暂不可用。"
|
||||||
|
: "\n请用对话语言清晰、友好地告诉用户:当前模型无法处理这类附件,建议切换到具备相应能力的多模态模型,或在「设置 → 模型」中配置视觉/视频模型作为旁路。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1356,7 +1368,7 @@ public abstract class BaseAgent {
|
|||||||
String contentType = part.getContentType();
|
String contentType = part.getContentType();
|
||||||
if (contentType == null || "image/*".equals(contentType)) contentType = "image/jpeg";
|
if (contentType == null || "image/*".equals(contentType)) contentType = "image/jpeg";
|
||||||
try {
|
try {
|
||||||
Media carried = new Media(MimeType.valueOf(contentType), new FileSystemResource(imgPath));
|
Media carried = new Media(MimeType.valueOf(contentType), mediaResource(imgPath));
|
||||||
UserMessage orig = built.userMessage();
|
UserMessage orig = built.userMessage();
|
||||||
String name = part.getFileName() == null ? "image" : part.getFileName();
|
String name = part.getFileName() == null ? "image" : part.getFileName();
|
||||||
String text = (orig.getText() == null ? "" : orig.getText())
|
String text = (orig.getText() == null ? "" : orig.getText())
|
||||||
@ -1400,10 +1412,27 @@ public abstract class BaseAgent {
|
|||||||
return isImage && !contentType.contains("svg");
|
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) {
|
protected Path resolveImagePath(String relativePath) {
|
||||||
if (relativePath == null || relativePath.isBlank()) {
|
if (relativePath == null || relativePath.isBlank()) {
|
||||||
return null;
|
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. 如果已经是绝对路径且存在,直接用
|
// 1. 如果已经是绝对路径且存在,直接用
|
||||||
Path path = Paths.get(relativePath);
|
Path path = Paths.get(relativePath);
|
||||||
if (path.isAbsolute() && Files.exists(path)) {
|
if (path.isAbsolute() && Files.exists(path)) {
|
||||||
|
|||||||
@ -182,6 +182,13 @@ public record ChatOrigin(
|
|||||||
executionAttribution, selectedGoalId);
|
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,
|
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
|
||||||
@Nullable String newWorkspaceBasePath) {
|
@Nullable String newWorkspaceBasePath) {
|
||||||
return new ChatOrigin(agentId, conversationId, requesterId,
|
return new ChatOrigin(agentId, conversationId, requesterId,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.agent.graph;
|
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.CompiledGraph;
|
||||||
import com.alibaba.cloud.ai.graph.NodeOutput;
|
import com.alibaba.cloud.ai.graph.NodeOutput;
|
||||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
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) {
|
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);
|
List<Message> historyMessages = buildConversationHistory(conversationId, userMessage);
|
||||||
|
|
||||||
@ -685,6 +689,8 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
}
|
}
|
||||||
origin = origin.withConversationId(conversationId)
|
origin = origin.withConversationId(conversationId)
|
||||||
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
||||||
|
origin = MemberFileIsolation.scope(origin);
|
||||||
|
inputs.put(WORKSPACE_BASE_PATH, origin.workspaceBasePath() != null ? origin.workspaceBasePath() : "");
|
||||||
inputs.put(CHAT_ORIGIN, origin);
|
inputs.put(CHAT_ORIGIN, origin);
|
||||||
|
|
||||||
// RFC 48 — inject active goal snapshot for GoalEvaluationNode.
|
// RFC 48 — inject active goal snapshot for GoalEvaluationNode.
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.agent.graph.executor;
|
package vip.mate.agent.graph.executor;
|
||||||
|
|
||||||
|
import vip.mate.workspace.core.service.MemberFileIsolation;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
@ -493,6 +494,8 @@ public class ToolExecutionExecutor {
|
|||||||
if (isBlank(safeOrigin.workspaceBasePath()) && !isBlank(workspaceBasePath)) {
|
if (isBlank(safeOrigin.workspaceBasePath()) && !isBlank(workspaceBasePath)) {
|
||||||
safeOrigin = safeOrigin.withWorkspace(safeOrigin.workspaceId(), 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
|
// 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
|
// same turn writes a single audit row; the set is repopulated by the
|
||||||
// denial branch below.
|
// denial branch below.
|
||||||
@ -579,6 +582,12 @@ public class ToolExecutionExecutor {
|
|||||||
// bypass guard rules keyed on the canonical name.
|
// bypass guard rules keyed on the canonical name.
|
||||||
String toolName = resolveToolName(toolCall.name());
|
String toolName = resolveToolName(toolCall.name());
|
||||||
String arguments = toolCall.arguments();
|
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));
|
events.add(GraphEventPublisher.toolStart(toolCall.id(), toolName, arguments));
|
||||||
|
|
||||||
@ -805,6 +814,7 @@ public class ToolExecutionExecutor {
|
|||||||
String conversationId, String workspaceBasePath,
|
String conversationId, String workspaceBasePath,
|
||||||
List<DirectToolOutput> directOutputs, ChatOrigin origin) {
|
List<DirectToolOutput> directOutputs, ChatOrigin origin) {
|
||||||
String toolName = resolveToolName(toolCall.name());
|
String toolName = resolveToolName(toolCall.name());
|
||||||
|
MemberFileIsolation.requireAuditedTool(toolName);
|
||||||
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
|
String callArguments = storedArguments != null ? storedArguments : toolCall.arguments();
|
||||||
|
|
||||||
ToolCallback callback = toolCallbackMap.get(toolName);
|
ToolCallback callback = toolCallbackMap.get(toolName);
|
||||||
@ -842,6 +852,8 @@ public class ToolExecutionExecutor {
|
|||||||
ChatOrigin replayOrigin = (origin == null ? ChatOrigin.EMPTY : origin)
|
ChatOrigin replayOrigin = (origin == null ? ChatOrigin.EMPTY : origin)
|
||||||
.withConversationId(conversationId);
|
.withConversationId(conversationId);
|
||||||
replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
|
replayOrigin = replayOrigin.withWorkspace(replayOrigin.workspaceId(), workspaceBasePath);
|
||||||
|
replayOrigin = MemberFileIsolation.scope(replayOrigin);
|
||||||
|
if (MemberFileIsolation.isEnabled()) workspaceBasePath = replayOrigin.workspaceBasePath();
|
||||||
String result = invokeObserved(callback, callArguments, toolContextWithScopedCatalog(replayOrigin),
|
String result = invokeObserved(callback, callArguments, toolContextWithScopedCatalog(replayOrigin),
|
||||||
UUID.randomUUID().toString(), toolCall.id());
|
UUID.randomUUID().toString(), toolCall.id());
|
||||||
throwIfStopRequested(conversationId);
|
throwIfStopRequested(conversationId);
|
||||||
@ -1645,6 +1657,7 @@ public class ToolExecutionExecutor {
|
|||||||
* to the usual {@code skillAwareNotFoundMessage} hint.
|
* to the usual {@code skillAwareNotFoundMessage} hint.
|
||||||
*/
|
*/
|
||||||
private SkillRedirect tryAutoRedirectSkillCall(String toolName, String originalArgs, ChatOrigin origin) {
|
private SkillRedirect tryAutoRedirectSkillCall(String toolName, String originalArgs, ChatOrigin origin) {
|
||||||
|
if (MemberFileIsolation.isEnabled()) return null;
|
||||||
if (skillRuntimeService == null || toolName == null || toolName.isBlank()) return null;
|
if (skillRuntimeService == null || toolName == null || toolName.isBlank()) return null;
|
||||||
try {
|
try {
|
||||||
// Scope to the conversation's workspace so an agent is never redirected
|
// 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,
|
private String invokeObserved(ToolCallback callback, String arguments, ToolContext context,
|
||||||
String invocationKey, String providerCallId) throws TimeoutException {
|
String invocationKey, String providerCallId) throws TimeoutException {
|
||||||
String toolName = callback.getToolDefinition().name();
|
String toolName = callback.getToolDefinition().name();
|
||||||
return ToolCallDeadline.call(toolName, getToolTimeoutMs(toolName),
|
MemberFileIsolation.requireAuditedTool(toolName);
|
||||||
() -> executionEvidenceRecorder == null ? callback.call(arguments, context)
|
ChatOrigin scoped = MemberFileIsolation.scope(ChatOrigin.from(context));
|
||||||
: executionEvidenceRecorder.invoke(callback, arguments, context, invocationKey, providerCallId));
|
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(); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 内部数据类 ====================
|
// ==================== 内部数据类 ====================
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.agent.graph.executor;
|
package vip.mate.agent.graph.executor;
|
||||||
|
|
||||||
|
import vip.mate.workspace.core.service.MemberFileIsolation;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
@ -165,8 +166,7 @@ public class ToolResultStorage {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(file.getParent());
|
writeSpill(file, result, workspaceBasePath);
|
||||||
Files.writeString(file, result, StandardCharsets.UTF_8);
|
|
||||||
} catch (IOException ioe) {
|
} catch (IOException ioe) {
|
||||||
log.warn("[ToolResultStorage] spill write failed for tool={} convId={} ({}); keeping original",
|
log.warn("[ToolResultStorage] spill write failed for tool={} convId={} ({}); keeping original",
|
||||||
toolName, conversationId, ioe.getMessage());
|
toolName, conversationId, ioe.getMessage());
|
||||||
@ -231,8 +231,7 @@ public class ToolResultStorage {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(file.getParent());
|
writeSpill(file, target.responseData(), workspaceBasePath);
|
||||||
Files.writeString(file, target.responseData(), StandardCharsets.UTF_8);
|
|
||||||
} catch (IOException ioe) {
|
} catch (IOException ioe) {
|
||||||
log.warn("[ToolResultStorage] spill write failed during turn budget enforcement: {}",
|
log.warn("[ToolResultStorage] spill write failed during turn budget enforcement: {}",
|
||||||
ioe.getMessage());
|
ioe.getMessage());
|
||||||
@ -312,6 +311,15 @@ public class ToolResultStorage {
|
|||||||
* Resolve the spill file path for a given (conversationId, toolUseId).
|
* Resolve the spill file path for a given (conversationId, toolUseId).
|
||||||
* Returns {@code null} if no usable directory can be determined.
|
* 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) {
|
private Path spillFor(String conversationId, String toolUseId, String workspaceBasePath) {
|
||||||
String safeConv = sanitize(conversationId);
|
String safeConv = sanitize(conversationId);
|
||||||
String safeId = sanitize(toolUseId);
|
String safeId = sanitize(toolUseId);
|
||||||
@ -326,7 +334,11 @@ public class ToolResultStorage {
|
|||||||
private Path resolveBaseDir(String workspaceBasePath) {
|
private Path resolveBaseDir(String workspaceBasePath) {
|
||||||
Path base;
|
Path base;
|
||||||
boolean outsideWorkspace;
|
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());
|
base = Paths.get(props.getStorageBaseDir());
|
||||||
outsideWorkspace = true;
|
outsideWorkspace = true;
|
||||||
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
|
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
|
||||||
@ -374,6 +386,8 @@ public class ToolResultStorage {
|
|||||||
* pointed at the historical workspace.
|
* pointed at the historical workspace.
|
||||||
*/
|
*/
|
||||||
public int cleanupExpired() {
|
public int cleanupExpired() {
|
||||||
|
// Legacy traversal cannot safely follow directories writable by confined code.
|
||||||
|
if (MemberFileIsolation.isEnabled()) return 0;
|
||||||
if (props.getRetentionDays() <= 0) {
|
if (props.getRetentionDays() <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@ -452,6 +466,7 @@ public class ToolResultStorage {
|
|||||||
* is simply left alone. Returns the number of files deleted.
|
* is simply left alone. Returns the number of files deleted.
|
||||||
*/
|
*/
|
||||||
public int purgeConversation(String conversationId) {
|
public int purgeConversation(String conversationId) {
|
||||||
|
if (MemberFileIsolation.isEnabled()) return 0;
|
||||||
if (conversationId == null || conversationId.isEmpty()) {
|
if (conversationId == null || conversationId.isEmpty()) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.agent.graph.plan;
|
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.CompiledGraph;
|
||||||
import com.alibaba.cloud.ai.graph.RunnableConfig;
|
import com.alibaba.cloud.ai.graph.RunnableConfig;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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) {
|
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 对齐)
|
// 加载会话历史(复用 BaseAgent.buildConversationHistory,与 ReAct 对齐)
|
||||||
List<Message> historyMessages = buildConversationHistory(conversationId, userMessage);
|
List<Message> historyMessages = buildConversationHistory(conversationId, userMessage);
|
||||||
|
|
||||||
@ -386,6 +390,8 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
}
|
}
|
||||||
origin = origin.withConversationId(conversationId)
|
origin = origin.withConversationId(conversationId)
|
||||||
.withWorkspace(origin.workspaceId(), workspaceBasePath);
|
.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);
|
inputs.put(MateClawStateKeys.CHAT_ORIGIN, origin);
|
||||||
|
|
||||||
// RFC 48 — inject active goal snapshot for GoalEvaluationNode.
|
// RFC 48 — inject active goal snapshot for GoalEvaluationNode.
|
||||||
|
|||||||
@ -165,6 +165,9 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AgentRuntimeConnection start(RuntimeSession session) {
|
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);
|
RuntimeValidation validation = validate(session);
|
||||||
if (!validation.valid()) {
|
if (!validation.valid()) {
|
||||||
throw new IllegalArgumentException(validation.code() + ": " + validation.message());
|
throw new IllegalArgumentException(validation.code() + ": " + validation.message());
|
||||||
@ -232,6 +235,9 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
Path workingDirectory,
|
Path workingDirectory,
|
||||||
AtomicReference<Process> processRef,
|
AtomicReference<Process> processRef,
|
||||||
AtomicReference<RuntimeContextUsage> latestUsage) {
|
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 -> {
|
return Flux.<AgentService.StreamDelta>create(sink -> {
|
||||||
Process process = null;
|
Process process = null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.agent.runtime.dsh;
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import vip.mate.workspace.core.service.MemberFileIsolation;
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@ -28,6 +29,7 @@ public final class DshToolDispatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DshToolDispatchResult dispatch(String toolName, String argumentsJson, Path targetPath) {
|
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);
|
ToolCallback callback = callbacks.get(toolName);
|
||||||
if (callback == null) return DshToolDispatchResult.denied("unknown tool");
|
if (callback == null) return DshToolDispatchResult.denied("unknown tool");
|
||||||
DshToolDecision decision = policyEvaluator.decide(policy, toolName, targetPath);
|
DshToolDecision decision = policyEvaluator.decide(policy, toolName, targetPath);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.channel.web;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@ -1222,10 +1223,20 @@ public class ChatController {
|
|||||||
|
|
||||||
@Operation(summary = "上传聊天附件")
|
@Operation(summary = "上传聊天附件")
|
||||||
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public R<ChatUploadResponse> upload(
|
public R<ChatUploadResponse> uploadInWorkspace(
|
||||||
@RequestParam String conversationId,
|
@RequestParam String conversationId,
|
||||||
@RequestPart("file") MultipartFile file,
|
@RequestPart("file") MultipartFile file,
|
||||||
|
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||||
Authentication auth) throws IOException {
|
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";
|
String username = auth != null ? auth.getName() : "anonymous";
|
||||||
// 校验会话归属(会话可能尚未创建,此时允许上传——后续 stream/chat 会创建并绑定用户)。
|
// 校验会话归属(会话可能尚未创建,此时允许上传——后续 stream/chat 会创建并绑定用户)。
|
||||||
@ -1242,14 +1253,23 @@ public class ChatController {
|
|||||||
String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
|
String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
|
||||||
String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_");
|
String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||||
String storedName = System.currentTimeMillis() + "_" + safeFilename;
|
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"
|
// resolveWriteDir sanitizes the id (IM-channel ids like "wecom:XXXX"
|
||||||
// carry a ':' illegal on Windows) and appends the per-day sub-directory
|
// carry a ':' illegal on Windows) and appends the per-day sub-directory
|
||||||
// when date folders are enabled. Reads probe both layouts.
|
// when date folders are enabled. Reads probe both layouts.
|
||||||
Path writeDir = uploadLocationResolver.resolveWriteDir(conversationId);
|
Path writeDir = MemberFileIsolation.isEnabled() ? uploadLocationResolver.resolveWriteDir(uploadOrigin)
|
||||||
Files.createDirectories(writeDir);
|
: uploadLocationResolver.resolveWriteDir(conversationId);
|
||||||
Path target = writeDir.resolve(storedName);
|
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);
|
log.info("Chat attachment uploaded: conversationId={}, user={}, file={}", conversationId, username, target);
|
||||||
|
|
||||||
@ -1278,12 +1298,25 @@ public class ChatController {
|
|||||||
return ResponseEntity.status(403).build();
|
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) {
|
if (filePath == null) {
|
||||||
return ResponseEntity.notFound().build();
|
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);
|
String contentType = Files.probeContentType(filePath);
|
||||||
// probeContentType 在部分平台不识别视频格式,通过扩展名 fallback
|
// probeContentType 在部分平台不识别视频格式,通过扩展名 fallback
|
||||||
if (contentType == null) {
|
if (contentType == null) {
|
||||||
@ -1318,6 +1351,8 @@ public class ChatController {
|
|||||||
return ResponseEntity.status(403).build();
|
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.
|
// 415: the client asked to preview a format this endpoint won't convert.
|
||||||
if (!officePreviewService.isConvertible(storedName)) {
|
if (!officePreviewService.isConvertible(storedName)) {
|
||||||
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build();
|
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).build();
|
||||||
@ -1327,7 +1362,16 @@ public class ChatController {
|
|||||||
return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build();
|
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) {
|
if (filePath == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.skill.runtime;
|
package vip.mate.skill.runtime;
|
||||||
|
|
||||||
|
import vip.mate.workspace.core.service.MemberFileIsolation;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@ -110,6 +111,7 @@ public class SkillScriptExecutionService {
|
|||||||
*/
|
*/
|
||||||
public ScriptResult executeCode(String language, String code, Path workingDir,
|
public ScriptResult executeCode(String language, String code, Path workingDir,
|
||||||
List<String> args, Map<String, String> envVars, Long timeoutSeconds) {
|
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()) {
|
if (code == null || code.isBlank()) {
|
||||||
return ScriptResult.error(-1, "No code supplied");
|
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,
|
private ScriptResult executeResolved(Path scriptPath, List<String> args, Map<String, String> envVars,
|
||||||
long timeoutSeconds, boolean scrubSensitiveEnv) {
|
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)) {
|
if (!Files.exists(scriptPath) || !Files.isRegularFile(scriptPath)) {
|
||||||
return ScriptResult.error(-1, "Script not found: " + scriptPath);
|
return ScriptResult.error(-1, "Script not found: " + scriptPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,13 @@ import org.springframework.context.event.EventListener;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.tool.model.ToolEntity;
|
import vip.mate.tool.model.ToolEntity;
|
||||||
import vip.mate.tool.repository.ToolMapper;
|
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.support.ToolCallbacks;
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
@ -53,7 +60,12 @@ public class ToolRegistry {
|
|||||||
private final CopyOnWriteArrayList<PluginToolEntry> pluginTools = new CopyOnWriteArrayList<>();
|
private final CopyOnWriteArrayList<PluginToolEntry> pluginTools = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
private final Object enabledToolSetLock = new Object();
|
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 */
|
/** A tool entry registered by a plugin */
|
||||||
public record PluginToolEntry(ToolCallback callback, Supplier<Boolean> availabilityCheck) {}
|
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.
|
* through the same {@code mate_agent_tool.tool_name} path as other tools.
|
||||||
*/
|
*/
|
||||||
public List<ToolCallback> listAvailablePluginTools() {
|
public List<ToolCallback> listAvailablePluginTools() {
|
||||||
|
if (MemberFileIsolation.isEnabled()) return List.of();
|
||||||
List<ToolCallback> out = new ArrayList<>();
|
List<ToolCallback> out = new ArrayList<>();
|
||||||
for (PluginToolEntry entry : pluginTools) {
|
for (PluginToolEntry entry : pluginTools) {
|
||||||
try {
|
try {
|
||||||
@ -174,6 +187,9 @@ public class ToolRegistry {
|
|||||||
for (Map.Entry<String, Object> entry : beans.entrySet()) {
|
for (Map.Entry<String, Object> entry : beans.entrySet()) {
|
||||||
String beanName = entry.getKey();
|
String beanName = entry.getKey();
|
||||||
Object bean = entry.getValue();
|
Object bean = entry.getValue();
|
||||||
|
if (enabledOnly && MemberFileIsolation.isEnabled() && !MEMBER_ISOLATED_BEANS.contains(bean.getClass())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
boolean hasToolMethod = java.util.Arrays.stream(bean.getClass().getMethods())
|
boolean hasToolMethod = java.util.Arrays.stream(bean.getClass().getMethods())
|
||||||
.anyMatch(m -> m.isAnnotationPresent(Tool.class));
|
.anyMatch(m -> m.isAnnotationPresent(Tool.class));
|
||||||
@ -281,17 +297,18 @@ public class ToolRegistry {
|
|||||||
* 2. 当前容器中所有 ToolCallbackProvider(MCP server 等)
|
* 2. 当前容器中所有 ToolCallbackProvider(MCP server 等)
|
||||||
*/
|
*/
|
||||||
public AgentToolSet getEnabledToolSet() {
|
public AgentToolSet getEnabledToolSet() {
|
||||||
AgentToolSet cached = enabledToolSetCache;
|
CachedToolSet cached = enabledToolSetCache;
|
||||||
if (cached != null) {
|
if (cached != null && cached.isolated() == MemberFileIsolation.isEnabled()) {
|
||||||
return cached;
|
return cached.tools();
|
||||||
}
|
}
|
||||||
synchronized (enabledToolSetLock) {
|
synchronized (enabledToolSetLock) {
|
||||||
cached = enabledToolSetCache;
|
cached = enabledToolSetCache;
|
||||||
if (cached != null) {
|
if (cached != null && cached.isolated() == MemberFileIsolation.isEnabled()) {
|
||||||
return cached;
|
return cached.tools();
|
||||||
}
|
}
|
||||||
|
boolean isolated = MemberFileIsolation.isEnabled();
|
||||||
AgentToolSet built = buildEnabledToolSet();
|
AgentToolSet built = buildEnabledToolSet();
|
||||||
enabledToolSetCache = built;
|
enabledToolSetCache = new CachedToolSet(built, isolated);
|
||||||
return built;
|
return built;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -307,7 +324,8 @@ public class ToolRegistry {
|
|||||||
nameByBean.put(e.getValue(), e.getKey());
|
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());
|
List<ToolCallbackProvider> providers = new ArrayList<>(providerBeans.values());
|
||||||
|
|
||||||
// 对内置工具 callback 应用 i18n 描述包装
|
// 对内置工具 callback 应用 i18n 描述包装
|
||||||
@ -340,7 +358,7 @@ public class ToolRegistry {
|
|||||||
|
|
||||||
// Plugin tool callbacks — evaluate availability checks lazily
|
// Plugin tool callbacks — evaluate availability checks lazily
|
||||||
int pluginToolCount = 0;
|
int pluginToolCount = 0;
|
||||||
for (PluginToolEntry entry : pluginTools) {
|
for (PluginToolEntry entry : MemberFileIsolation.isEnabled() ? List.<PluginToolEntry>of() : pluginTools) {
|
||||||
try {
|
try {
|
||||||
if (Boolean.TRUE.equals(entry.availabilityCheck().get())) {
|
if (Boolean.TRUE.equals(entry.availabilityCheck().get())) {
|
||||||
localizedCallbacks.add(entry.callback());
|
localizedCallbacks.add(entry.callback());
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package vip.mate.tool.builtin;
|
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.JSONObject;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@ -52,10 +54,12 @@ public class AppendFileTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Path parent = path.getParent();
|
Path parent = path.getParent();
|
||||||
if (parent != null) Files.createDirectories(parent);
|
if (!MemberFileIsolation.isEnabled() && parent != null) Files.createDirectories(parent);
|
||||||
|
|
||||||
boolean existed = Files.exists(path);
|
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)) {
|
if (current.endsWith(content)) {
|
||||||
JSONObject result = baseResult(filePath);
|
JSONObject result = baseResult(filePath);
|
||||||
result.set("bytesWritten", 0);
|
result.set("bytesWritten", 0);
|
||||||
@ -70,7 +74,8 @@ public class AppendFileTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
|
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);
|
JSONObject result = baseResult(filePath);
|
||||||
result.set("bytesWritten", bytes.length);
|
result.set("bytesWritten", bytes.length);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.tool.builtin;
|
package vip.mate.tool.builtin;
|
||||||
|
|
||||||
|
import vip.mate.workspace.core.service.MemberFileIsolation;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
||||||
|
|
||||||
@ -83,7 +84,8 @@ public final class ChatUploadResolver {
|
|||||||
for (Path uploadDir : candidateUploadDirs(conversationId)) {
|
for (Path uploadDir : candidateUploadDirs(conversationId)) {
|
||||||
Path matched = resolveIn(rawPath, uploadDir);
|
Path matched = resolveIn(rawPath, uploadDir);
|
||||||
if (matched != null) {
|
if (matched != null) {
|
||||||
return matched;
|
return MemberFileIsolation.isEnabled()
|
||||||
|
? MemberFileIsolation.validate(MemberFileIsolation.root(null), matched.toString()) : matched;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -97,6 +99,11 @@ public final class ChatUploadResolver {
|
|||||||
*/
|
*/
|
||||||
private static List<Path> candidateUploadDirs(String conversationId) {
|
private static List<Path> candidateUploadDirs(String conversationId) {
|
||||||
Set<Path> dirs = new LinkedHashSet<>();
|
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();
|
String basePath = ToolExecutionContext.workspaceBasePath();
|
||||||
if (basePath != null && !basePath.isBlank()) {
|
if (basePath != null && !basePath.isBlank()) {
|
||||||
Path scopedRoot = Paths.get(basePath).toAbsolutePath().normalize().resolve(UPLOAD_SUBDIR);
|
Path scopedRoot = Paths.get(basePath).toAbsolutePath().normalize().resolve(UPLOAD_SUBDIR);
|
||||||
|
|||||||
@ -64,6 +64,9 @@ public class CodeExecuteTool {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final GeneratedFileCache generatedFileCache;
|
private final GeneratedFileCache generatedFileCache;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private vip.mate.tool.sandbox.MemberSandboxExecutor memberSandboxExecutor;
|
||||||
|
|
||||||
@Lazy
|
@Lazy
|
||||||
@Autowired
|
@Autowired
|
||||||
private AgentBindingResolver agentBindingResolver;
|
private AgentBindingResolver agentBindingResolver;
|
||||||
@ -117,6 +120,9 @@ public class CodeExecuteTool {
|
|||||||
log.info("[CodeExecute] language={}, skill={}, codeChars={}",
|
log.info("[CodeExecute] language={}, skill={}, codeChars={}",
|
||||||
language, skillName, code == null ? 0 : code.length());
|
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;
|
Path workingDir;
|
||||||
Map<String, String> envVars = Collections.emptyMap();
|
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 per element, anything else is forwarded verbatim as a single
|
||||||
* argument (so a bare date / version string is never mangled by JSON parsing).
|
* 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) {
|
List<String> normalizeArgs(String args) {
|
||||||
if (args == null) {
|
if (args == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package vip.mate.tool.builtin;
|
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.JSONObject;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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 是否存在
|
// 检查 oldText 是否存在
|
||||||
if (!content.contains(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("replacements", replacements);
|
||||||
result.set("replaceAll", doReplaceAll);
|
result.set("replaceAll", doReplaceAll);
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package vip.mate.tool.builtin;
|
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.JSONObject;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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();
|
int totalLines = allLines.size();
|
||||||
result.set("totalLines", totalLines);
|
result.set("totalLines", totalLines);
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,9 @@ public class ShellExecuteTool {
|
|||||||
private final vip.mate.i18n.I18nService i18n;
|
private final vip.mate.i18n.I18nService i18n;
|
||||||
private final GeneratedFileCache generatedFileCache;
|
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 DEFAULT_TIMEOUT_SECONDS = 60;
|
||||||
private static final int MAX_OUTPUT_BYTES = 10_000;
|
private static final int MAX_OUTPUT_BYTES = 10_000;
|
||||||
private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
|
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.
|
// ChatOrigin so the workspace boundary check honors per-agent basePath.
|
||||||
@Nullable ToolContext ctx) {
|
@Nullable ToolContext ctx) {
|
||||||
|
|
||||||
|
if (vip.mate.workspace.core.service.MemberFileIsolation.isEnabled()) {
|
||||||
|
return executeIsolated(command, timeoutSeconds, ctx);
|
||||||
|
}
|
||||||
ExecutionObservationSink evidence = ExecutionObservationSink.from(ctx);
|
ExecutionObservationSink evidence = ExecutionObservationSink.from(ctx);
|
||||||
int timeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : DEFAULT_TIMEOUT_SECONDS;
|
int timeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : DEFAULT_TIMEOUT_SECONDS;
|
||||||
// 硬上限:不允许超过 300 秒
|
// 硬上限:不允许超过 300 秒
|
||||||
@ -195,6 +201,35 @@ public class ShellExecuteTool {
|
|||||||
* from the calling environment still apply; falls back to /bin/sh
|
* from the calling environment still apply; falls back to /bin/sh
|
||||||
* when $SHELL is unset or points at a non-executable path.
|
* 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) {
|
private static ProcessBuilder buildShellProcess(String command, @Nullable ToolContext ctx) {
|
||||||
ProcessBuilder pb;
|
ProcessBuilder pb;
|
||||||
if (IS_WINDOWS) {
|
if (IS_WINDOWS) {
|
||||||
|
|||||||
@ -20,6 +20,8 @@ import vip.mate.agent.context.ChatOrigin;
|
|||||||
*/
|
*/
|
||||||
public final class ToolExecutionContext {
|
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> CONVERSATION_ID = new ThreadLocal<>();
|
||||||
private static final ThreadLocal<String> USERNAME = new ThreadLocal<>();
|
private static final ThreadLocal<String> USERNAME = new ThreadLocal<>();
|
||||||
/** 工作区活动目录(为空不限制) */
|
/** 工作区活动目录(为空不限制) */
|
||||||
@ -28,12 +30,14 @@ public final class ToolExecutionContext {
|
|||||||
private ToolExecutionContext() {}
|
private ToolExecutionContext() {}
|
||||||
|
|
||||||
public static void set(String conversationId, String username) {
|
public static void set(String conversationId, String username) {
|
||||||
|
ORIGIN.remove();
|
||||||
CONVERSATION_ID.set(conversationId);
|
CONVERSATION_ID.set(conversationId);
|
||||||
USERNAME.set(username);
|
USERNAME.set(username);
|
||||||
WORKSPACE_BASE_PATH.remove();
|
WORKSPACE_BASE_PATH.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void set(String conversationId, String username, String workspaceBasePath) {
|
public static void set(String conversationId, String username, String workspaceBasePath) {
|
||||||
|
ORIGIN.remove();
|
||||||
CONVERSATION_ID.set(conversationId);
|
CONVERSATION_ID.set(conversationId);
|
||||||
USERNAME.set(username);
|
USERNAME.set(username);
|
||||||
WORKSPACE_BASE_PATH.set(workspaceBasePath);
|
WORKSPACE_BASE_PATH.set(workspaceBasePath);
|
||||||
@ -52,7 +56,11 @@ public final class ToolExecutionContext {
|
|||||||
return WORKSPACE_BASE_PATH.get();
|
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() {
|
public static void clear() {
|
||||||
|
ORIGIN.remove();
|
||||||
CONVERSATION_ID.remove();
|
CONVERSATION_ID.remove();
|
||||||
USERNAME.remove();
|
USERNAME.remove();
|
||||||
WORKSPACE_BASE_PATH.remove();
|
WORKSPACE_BASE_PATH.remove();
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package vip.mate.tool.builtin;
|
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.JSONObject;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@ -71,7 +73,7 @@ public class WriteFileTool {
|
|||||||
|
|
||||||
// 自动创建父目录
|
// 自动创建父目录
|
||||||
Path parent = path.getParent();
|
Path parent = path.getParent();
|
||||||
if (parent != null && !Files.exists(parent)) {
|
if (!MemberFileIsolation.isEnabled() && parent != null && !Files.exists(parent)) {
|
||||||
Files.createDirectories(parent);
|
Files.createDirectories(parent);
|
||||||
log.info("[WriteFile] Created parent directories: {}", parent);
|
log.info("[WriteFile] Created parent directories: {}", parent);
|
||||||
}
|
}
|
||||||
@ -80,7 +82,8 @@ public class WriteFileTool {
|
|||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
|
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("bytesWritten", bytes.length);
|
||||||
result.set("created", !existed);
|
result.set("created", !existed);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package vip.mate.tool.document;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@ -45,7 +46,9 @@ public class GeneratedFileController {
|
|||||||
if (user == null) {
|
if (user == null) {
|
||||||
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
|
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) {
|
if (access.status() == GeneratedFileCache.AccessStatus.FORBIDDEN) {
|
||||||
return ResponseEntity.status(403).body(Map.of("error", "Workspace permission denied"));
|
return ResponseEntity.status(403).body(Map.of("error", "Workspace permission denied"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package vip.mate.tool.document;
|
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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.model.ToolContext;
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
@ -71,7 +73,8 @@ public final class WorkspaceArtifactSurfacer {
|
|||||||
if (!attrs.isRegularFile() || size <= 0 || size > budget) {
|
if (!attrs.isRegularFile() || size <= 0 || size > budget) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
byte[] bytes = readArtifact(p, budget);
|
byte[] bytes = MemberFileIsolation.isEnabled()
|
||||||
|
? MemberFileAccess.read(MemberFileIsolation.root(ctx), p, budget) : readArtifact(p, budget);
|
||||||
if (bytes.length == 0) {
|
if (bytes.length == 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package vip.mate.tool.guard;
|
package vip.mate.tool.guard;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import vip.mate.workspace.core.service.MemberFileIsolation;
|
||||||
import org.springframework.ai.chat.model.ToolContext;
|
import org.springframework.ai.chat.model.ToolContext;
|
||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
@ -204,6 +205,7 @@ public final class WorkspacePathGuard {
|
|||||||
* transition window.
|
* transition window.
|
||||||
*/
|
*/
|
||||||
public static Path validatePath(String rawPath, @Nullable ToolContext ctx) {
|
public static Path validatePath(String rawPath, @Nullable ToolContext ctx) {
|
||||||
|
if (MemberFileIsolation.isEnabled()) return MemberFileIsolation.validate(MemberFileIsolation.root(ctx), rawPath);
|
||||||
String basePath = resolveBasePath(ctx);
|
String basePath = resolveBasePath(ctx);
|
||||||
if (basePath == null || basePath.isBlank()) {
|
if (basePath == null || basePath.isBlank()) {
|
||||||
// 未配置活动目录,不限制。此时相对路径仍按进程 CWD 解析(遗留行为)。
|
// 未配置活动目录,不限制。此时相对路径仍按进程 CWD 解析(遗留行为)。
|
||||||
@ -607,6 +609,7 @@ public final class WorkspacePathGuard {
|
|||||||
* </ol>
|
* </ol>
|
||||||
*/
|
*/
|
||||||
private static String resolveBasePath(@Nullable ToolContext ctx) {
|
private static String resolveBasePath(@Nullable ToolContext ctx) {
|
||||||
|
if (MemberFileIsolation.isEnabled()) return MemberFileIsolation.root(ctx).toString();
|
||||||
if (ctx != null) {
|
if (ctx != null) {
|
||||||
ChatOrigin origin = ChatOrigin.from(ctx);
|
ChatOrigin origin = ChatOrigin.from(ctx);
|
||||||
if (origin.workspaceBasePath() != null && !origin.workspaceBasePath().isBlank()) {
|
if (origin.workspaceBasePath() != null && !origin.workspaceBasePath().isBlank()) {
|
||||||
|
|||||||
@ -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]" : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1851,6 +1851,8 @@ public class ConversationService {
|
|||||||
* directory or the pre-migration default directory.
|
* directory or the pre-migration default directory.
|
||||||
*/
|
*/
|
||||||
public void cleanAttachmentFiles(String conversationId) {
|
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()) {
|
if (conversationId == null || conversationId.isBlank()) {
|
||||||
// A blank id would resolve to the upload root itself and wipe every
|
// A blank id would resolve to the upload root itself and wipe every
|
||||||
// conversation's attachments — never walk/delete a bare root.
|
// conversation's attachments — never walk/delete a bare root.
|
||||||
|
|||||||
@ -27,6 +27,9 @@ public class WorkspaceSandboxProperties {
|
|||||||
*/
|
*/
|
||||||
private boolean enabled = true;
|
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.
|
* Global fallback sandbox root, used when no per-workspace base path is set.
|
||||||
* Defaults to {@code <working dir>/data/workspace}, alongside the H2 data
|
* Defaults to {@code <working dir>/data/workspace}, alongside the H2 data
|
||||||
|
|||||||
@ -202,7 +202,17 @@ public class ChatUploadLocationResolver {
|
|||||||
* (flat + date sub-directories). The single entry point for serving /
|
* (flat + date sub-directories). The single entry point for serving /
|
||||||
* download paths; returns {@code null} when no candidate holds the file.
|
* 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) {
|
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)) {
|
for (Path conversationDir : resolveCandidateConversationDirs(conversationId)) {
|
||||||
Path found = findInConversationDir(conversationDir, storedName);
|
Path found = findInConversationDir(conversationDir, storedName);
|
||||||
if (found != null) {
|
if (found != null) {
|
||||||
@ -276,7 +286,24 @@ public class ChatUploadLocationResolver {
|
|||||||
* @param conversationId business conversation id
|
* @param conversationId business conversation id
|
||||||
* @return absolute, normalized upload root (never {@code null})
|
* @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) {
|
public Path resolveUploadRoot(String conversationId) {
|
||||||
|
if (MemberFileIsolation.isEnabled()) return resolveUploadRoot(
|
||||||
|
vip.mate.agent.context.ChatOrigin.EMPTY.withConversationId(conversationId));
|
||||||
ConversationEntity conv = lookupConversation(conversationId);
|
ConversationEntity conv = lookupConversation(conversationId);
|
||||||
Long workspaceId = conv != null ? conv.getWorkspaceId() : null;
|
Long workspaceId = conv != null ? conv.getWorkspaceId() : null;
|
||||||
Long agentId = conv != null ? conv.getAgentId() : null;
|
Long agentId = conv != null ? conv.getAgentId() : null;
|
||||||
@ -289,6 +316,7 @@ public class ChatUploadLocationResolver {
|
|||||||
* picked agent), avoiding a DB lookup.
|
* picked agent), avoiding a DB lookup.
|
||||||
*/
|
*/
|
||||||
public Path resolveUploadRoot(Long workspaceId, Long agentId) {
|
public Path resolveUploadRoot(Long workspaceId, Long agentId) {
|
||||||
|
if (MemberFileIsolation.isEnabled()) throw new SecurityException("Member identity required for upload storage");
|
||||||
Path resolved = resolveWorkspaceScopedRoot(workspaceId, agentId);
|
Path resolved = resolveWorkspaceScopedRoot(workspaceId, agentId);
|
||||||
if (resolved != null) {
|
if (resolved != null) {
|
||||||
return resolved;
|
return resolved;
|
||||||
@ -307,6 +335,7 @@ public class ChatUploadLocationResolver {
|
|||||||
* @return de-duplicated, ordered list (at least the default root is present)
|
* @return de-duplicated, ordered list (at least the default root is present)
|
||||||
*/
|
*/
|
||||||
public List<Path> resolveCandidateUploadRoots(String conversationId) {
|
public List<Path> resolveCandidateUploadRoots(String conversationId) {
|
||||||
|
if (MemberFileIsolation.isEnabled()) return List.of(resolveUploadRoot(conversationId));
|
||||||
ConversationEntity conv = lookupConversation(conversationId);
|
ConversationEntity conv = lookupConversation(conversationId);
|
||||||
Long workspaceId = conv != null ? conv.getWorkspaceId() : null;
|
Long workspaceId = conv != null ? conv.getWorkspaceId() : null;
|
||||||
Long agentId = conv != null ? conv.getAgentId() : null;
|
Long agentId = conv != null ? conv.getAgentId() : null;
|
||||||
@ -318,6 +347,7 @@ public class ChatUploadLocationResolver {
|
|||||||
* already hold the workspace / agent ids.
|
* already hold the workspace / agent ids.
|
||||||
*/
|
*/
|
||||||
public List<Path> resolveCandidateUploadRoots(Long workspaceId, Long agentId) {
|
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<>();
|
Set<Path> roots = new LinkedHashSet<>();
|
||||||
Path scoped = resolveWorkspaceScopedRoot(workspaceId, agentId);
|
Path scoped = resolveWorkspaceScopedRoot(workspaceId, agentId);
|
||||||
if (scoped != null) {
|
if (scoped != null) {
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"); }
|
||||||
|
}
|
||||||
@ -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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"; } }
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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-")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user