mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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.ai.tool.annotation.ToolParam;
|
||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@ -75,24 +77,54 @@ public class DocumentExtractTool {
|
|||||||
// 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) {
|
||||||
|
|
||||||
|
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();
|
JSONObject result = new JSONObject();
|
||||||
result.set("filePath", filePath);
|
result.set("filePath", filePath);
|
||||||
List<String> attempts = new ArrayList<>();
|
List<String> attempts = new ArrayList<>();
|
||||||
|
|
||||||
try {
|
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)) {
|
if (!Files.exists(path)) {
|
||||||
// The user-uploaded chat attachment is rendered to the LLM as
|
// The user-uploaded chat attachment is rendered to the LLM as
|
||||||
// "[附件] foo.docx" without its stored path, and Chinese / non-ASCII
|
// "[附件] foo.docx" without its stored path, and Chinese / non-ASCII
|
||||||
|
|||||||
@ -633,7 +633,10 @@ public class WikiRawMaterialService {
|
|||||||
// 二进制文件:调用 DocumentExtractTool 提取
|
// 二进制文件:调用 DocumentExtractTool 提取
|
||||||
if (entity.getSourcePath() != null && !entity.getSourcePath().isBlank()) {
|
if (entity.getSourcePath() != null && !entity.getSourcePath().isBlank()) {
|
||||||
try {
|
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);
|
JSONObject json = JSONUtil.parseObj(result);
|
||||||
if (json.getBool("success", false)) {
|
if (json.getBool("success", false)) {
|
||||||
String text = json.getStr("text");
|
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