diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java index 4763ab9b..aa37e770 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocumentExtractTool.java @@ -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. + *
+ * 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 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 sibling 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.
+ *
+ * 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