mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
fix(wiki): extract uploaded documents via a sandbox-exempt path (#323)
This commit is contained in:
parent
48024e4a83
commit
936c8621ed
@ -8,10 +8,12 @@ import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@ -75,24 +77,54 @@ public class DocumentExtractTool {
|
||||
// ChatOrigin so the workspace boundary check honors per-agent basePath.
|
||||
@Nullable ToolContext ctx) {
|
||||
|
||||
Path path;
|
||||
try {
|
||||
path = WorkspacePathGuard.validatePath(filePath, ctx);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Sandbox rejected the literal path. Try chat-upload basename
|
||||
// resolution before surfacing the boundary error.
|
||||
Path attachment = ChatUploadResolver.resolve(filePath);
|
||||
if (attachment == null) {
|
||||
return errorResult(filePath, e.getMessage(), new ArrayList<>());
|
||||
}
|
||||
path = attachment;
|
||||
}
|
||||
return extractResolved(filePath, path, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal, sandbox-exempt extraction for server-managed file paths.
|
||||
* <p>
|
||||
* The wiki ingest pipeline stages an uploaded raw material under its own
|
||||
* upload directory and feeds that stored path straight back here. The path
|
||||
* is produced by the server, never by the model, so the workspace boundary
|
||||
* guard — whose job is to stop the LLM reading arbitrary disk locations —
|
||||
* must not apply: the wiki upload dir is a sibling of the global sandbox root
|
||||
* and would otherwise be rejected as "outside workspace boundary", surfacing
|
||||
* to the user as "No text content available". Callers must pass a path the
|
||||
* server itself produced, not anything derived from model output.
|
||||
*
|
||||
* @param filePath absolute, server-controlled path to the staged document
|
||||
* @param options same options JSON accepted by {@link #extract_document_text}
|
||||
*/
|
||||
public String extractTrustedDocument(String filePath, String options) {
|
||||
Path path = Paths.get(filePath).toAbsolutePath().normalize();
|
||||
return extractResolved(filePath, path, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared extraction body running on an already-resolved {@link Path}: detects
|
||||
* the document type and drives the per-format extractor chain. Both the
|
||||
* sandbox-guarded {@link #extract_document_text} tool entry and the trusted
|
||||
* {@link #extractTrustedDocument} internal entry funnel through here so the
|
||||
* extraction logic stays in one place.
|
||||
*/
|
||||
private String extractResolved(String filePath, Path path, String options) {
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("filePath", filePath);
|
||||
List<String> attempts = new ArrayList<>();
|
||||
|
||||
try {
|
||||
Path path;
|
||||
try {
|
||||
path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath, ctx);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Sandbox rejected the literal path. Try chat-upload basename
|
||||
// resolution before surfacing the boundary error.
|
||||
Path attachment = ChatUploadResolver.resolve(filePath);
|
||||
if (attachment == null) {
|
||||
return errorResult(filePath, e.getMessage(), attempts);
|
||||
}
|
||||
path = attachment;
|
||||
}
|
||||
|
||||
if (!Files.exists(path)) {
|
||||
// The user-uploaded chat attachment is rendered to the LLM as
|
||||
// "[附件] foo.docx" without its stored path, and Chinese / non-ASCII
|
||||
|
||||
@ -633,7 +633,10 @@ public class WikiRawMaterialService {
|
||||
// 二进制文件:调用 DocumentExtractTool 提取
|
||||
if (entity.getSourcePath() != null && !entity.getSourcePath().isBlank()) {
|
||||
try {
|
||||
String result = documentExtractTool.extract_document_text(entity.getSourcePath(), null, null);
|
||||
// Server-managed path (staged under the wiki upload dir): use the
|
||||
// sandbox-exempt entry so the workspace boundary guard does not
|
||||
// reject the upload dir as "outside workspace boundary".
|
||||
String result = documentExtractTool.extractTrustedDocument(entity.getSourcePath(), null);
|
||||
JSONObject json = JSONUtil.parseObj(result);
|
||||
if (json.getBool("success", false)) {
|
||||
String text = json.getStr("text");
|
||||
|
||||
@ -0,0 +1,120 @@
|
||||
package vip.mate.tool.builtin;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.DisabledOnOs;
|
||||
import org.junit.jupiter.api.condition.OS;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Regression coverage for issue #323: uploading a .docx (or any binary file) to
|
||||
* the wiki failed with "No text content available".
|
||||
*
|
||||
* <p>Root cause: the wiki ingest pipeline stages uploads under its own upload
|
||||
* directory (default {@code ./data/wiki-uploads}) and feeds that path back into
|
||||
* {@link DocumentExtractTool} for text extraction. With the workspace sandbox
|
||||
* enabled (the default), the global fallback root is {@code ./data/workspace} —
|
||||
* a <i>sibling</i> of the upload dir. The boundary guard therefore rejected the
|
||||
* server's own staged path as "outside workspace boundary", the extractor
|
||||
* returned {@code success=false}, and the wiki fell back to a null body.
|
||||
*
|
||||
* <p>The fix routes the internal, server-controlled path through
|
||||
* {@link DocumentExtractTool#extractTrustedDocument} which skips the LLM-oriented
|
||||
* boundary guard. These tests pin both halves: the guarded tool entry still
|
||||
* rejects an out-of-sandbox path (demonstrating the bug), and the trusted entry
|
||||
* extracts it successfully (verifying the fix).
|
||||
*/
|
||||
@DisabledOnOs(OS.WINDOWS) // POSIX-style absolute paths / sandbox roots in these cases
|
||||
class DocumentExtractToolTrustedPathTest {
|
||||
|
||||
private static final String TOKEN = "REGRESSION_TOKEN_323";
|
||||
|
||||
private final DocumentExtractTool tool = new DocumentExtractTool();
|
||||
|
||||
@TempDir
|
||||
Path sandboxRoot; // stands in for ./data/workspace
|
||||
|
||||
@TempDir
|
||||
Path uploadDir; // stands in for ./data/wiki-uploads (a sibling, outside the sandbox)
|
||||
|
||||
private Path docx;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
// Simulate the out-of-the-box state: sandbox enabled with a fallback root,
|
||||
// no per-conversation workspace configured.
|
||||
ToolExecutionContext.clear();
|
||||
WorkspacePathGuard.setDefaultRoot(sandboxRoot.toString());
|
||||
docx = uploadDir.resolve(System.currentTimeMillis() + "_regression-323.docx");
|
||||
writeMinimalDocx(docx, TOKEN + " hello world");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void teardown() {
|
||||
ToolExecutionContext.clear();
|
||||
WorkspacePathGuard.setDefaultRoot(null);
|
||||
WorkspacePathGuard.setSkillRoot(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Guarded tool entry rejects the staged upload path (the #323 failure)")
|
||||
void guardedEntry_rejectsOutsideSandbox() {
|
||||
JSONObject result = JSONUtil.parseObj(
|
||||
tool.extract_document_text(docx.toString(), null, null));
|
||||
assertThat(result.getBool("success", false))
|
||||
.as("an upload-dir path sits outside the sandbox root and must be blocked by the guard")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Trusted entry extracts the staged upload path (the #323 fix)")
|
||||
void trustedEntry_extractsOutsideSandbox() {
|
||||
JSONObject result = JSONUtil.parseObj(
|
||||
tool.extractTrustedDocument(docx.toString(), null));
|
||||
assertThat(result.getBool("success", false))
|
||||
.as("server-managed path must bypass the sandbox and extract successfully")
|
||||
.isTrue();
|
||||
assertThat(result.getStr("text")).contains(TOKEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a minimal but valid-enough .docx: a ZIP whose {@code word/document.xml}
|
||||
* carries the text inside {@code <w:t>} runs. This is exactly what the pure-Java
|
||||
* ZIP-XML extractor in {@link DocumentExtractTool} reads, so the test needs no
|
||||
* external tools (textutil / pandoc / libreoffice) to be installed.
|
||||
*/
|
||||
private static void writeMinimalDocx(Path target, String body) throws Exception {
|
||||
String contentTypes = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
</Types>""";
|
||||
String documentXml = """
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body><w:p><w:r><w:t>%s</w:t></w:r></w:p></w:body>
|
||||
</w:document>""".formatted(body);
|
||||
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(target))) {
|
||||
zos.putNextEntry(new ZipEntry("[Content_Types].xml"));
|
||||
zos.write(contentTypes.getBytes(StandardCharsets.UTF_8));
|
||||
zos.closeEntry();
|
||||
zos.putNextEntry(new ZipEntry("word/document.xml"));
|
||||
zos.write(documentXml.getBytes(StandardCharsets.UTF_8));
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user