fix(wiki): extract uploaded documents via a sandbox-exempt path (#323)

This commit is contained in:
matevip 2026-06-13 07:29:39 +08:00
parent 48024e4a83
commit 936c8621ed
3 changed files with 169 additions and 14 deletions

View File

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

View File

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

View File

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