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 b3fc54e2..4763ab9b 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 @@ -3,14 +3,15 @@ package vip.mate.tool.builtin; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; 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 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; @@ -69,14 +70,28 @@ public class DocumentExtractTool { """) public String extract_document_text( @ToolParam(description = "文件的绝对路径或相对路径") String filePath, - @ToolParam(description = "可选参数 JSON,如 {\"pages\": \"1-5\", \"method\": \"tika\"}", required = false) String options) { + @ToolParam(description = "可选参数 JSON,如 {\"pages\": \"1-5\", \"method\": \"tika\"}", required = false) String options, + // RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator. Carries the + // ChatOrigin so the workspace boundary check honors per-agent basePath. + @Nullable ToolContext ctx) { JSONObject result = new JSONObject(); result.set("filePath", filePath); List attempts = new ArrayList<>(); try { - Path path = Paths.get(filePath).toAbsolutePath().normalize(); + 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 @@ -185,10 +200,11 @@ public class DocumentExtractTool { """) public String extract_pdf_text( @ToolParam(description = "PDF 文件的绝对路径或相对路径") String filePath, - @ToolParam(description = "页码范围,如 \"1-5\" 或 \"1,3,5\"", required = false) String pages) { + @ToolParam(description = "页码范围,如 \"1-5\" 或 \"1,3,5\"", required = false) String pages, + @Nullable ToolContext ctx) { String options = pages != null ? "{\"pages\": \"" + pages + "\"}" : null; - return extract_document_text(filePath, options); + return extract_document_text(filePath, options, ctx); } @Tool(description = """ @@ -201,8 +217,9 @@ public class DocumentExtractTool { 支持 .docx 和 .doc 格式 """) public String extract_docx_text( - @ToolParam(description = "Word 文档的绝对路径或相对路径") String filePath) { - return extract_document_text(filePath, null); + @ToolParam(description = "Word 文档的绝对路径或相对路径") String filePath, + @Nullable ToolContext ctx) { + return extract_document_text(filePath, null, ctx); } // ==================== PDF 提取链 ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java index b423e6be..84d273f7 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/FileTypeDetectorTool.java @@ -3,8 +3,10 @@ package vip.mate.tool.builtin; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; 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 java.io.BufferedReader; @@ -12,7 +14,6 @@ import java.io.IOException; import java.io.InputStreamReader; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.concurrent.TimeUnit; /** @@ -41,13 +42,28 @@ public class FileTypeDetectorTool { 注意:对于 .docx/.pdf 等文档,不会返回 read_file,而是 extract_document_text """) public String detect_file_type( - @ToolParam(description = "文件的绝对路径或相对路径") String filePath) { + @ToolParam(description = "文件的绝对路径或相对路径") String filePath, + // RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator. Carries the + // ChatOrigin so the workspace boundary check honors per-agent basePath. + @Nullable ToolContext ctx) { JSONObject result = new JSONObject(); result.set("filePath", filePath); try { - Path path = Paths.get(filePath).toAbsolutePath().normalize(); + Path path; + try { + path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath, ctx); + } catch (IllegalArgumentException e) { + // Sandbox rejected the literal path. Fall back to chat-upload + // basename matching before surfacing the boundary error — the + // LLM may have hallucinated a system path for a real attachment. + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, e.getMessage()); + } + path = attachment; + } if (!Files.exists(path)) { // Fall back to chat-upload basename matching for filenames that were diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java index 8475f969..f0c9067e 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java @@ -3,8 +3,10 @@ package vip.mate.tool.builtin; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; 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 java.io.IOException; @@ -51,7 +53,10 @@ public class ShellExecuteTool { + "Dangerous operations trigger security approval. Returns structured result with exitCode, stdout, stderr, timedOut.") public String execute_shell_command( @ToolParam(description = "Shell command to execute") String command, - @ToolParam(description = "Timeout in seconds, default 60", required = false) Integer timeoutSeconds) { + @ToolParam(description = "Timeout in seconds, default 60", required = false) Integer timeoutSeconds, + // RFC-063r §2.5: hidden from LLM by JsonSchemaGenerator. Carries the + // ChatOrigin so the workspace boundary check honors per-agent basePath. + @Nullable ToolContext ctx) { int timeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : DEFAULT_TIMEOUT_SECONDS; // 硬上限:不允许超过 300 秒 @@ -63,6 +68,21 @@ public class ShellExecuteTool { JSONObject result = new JSONObject(); result.set("command", command); + // Enforce the workspace boundary on the command string itself before + // the process starts. The pb.directory() set later only constrains + // the CWD — absolute paths in the command would still reach anywhere. + try { + vip.mate.tool.guard.WorkspacePathGuard.validateShellCommand(command, ctx); + } catch (IllegalArgumentException e) { + log.warn("[ShellExecute] Sandbox rejected command: {}", e.getMessage()); + result.set("exitCode", -1); + result.set("stdout", ""); + result.set("stderr", e.getMessage()); + result.set("timedOut", false); + result.set("error", e.getMessage()); + return JSONUtil.toJsonPrettyStr(result); + } + Path stdoutFile = null; Path stderrFile = null; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java index d6112d7d..5569aa8e 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/WorkspacePathGuard.java @@ -9,6 +9,8 @@ import vip.mate.tool.builtin.ToolExecutionContext; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * 工作区路径沙箱校验器 @@ -100,6 +102,119 @@ public final class WorkspacePathGuard { return Paths.get(basePath).toAbsolutePath().normalize(); } + /** + * Validate that a shell command does not reference filesystem locations + * outside the active workspace boundary. When no workspace basePath is + * configured, the check is a no-op (matching {@link #validatePath} semantics). + * + *

The check is a static scan of the literal command string. It rejects: + *

+ * + *

Limitations — the static scan is a best-effort defense, not a + * true filesystem sandbox. Obfuscated forms ({@code /e''tc/passwd}, + * variable concatenation like {@code X=/etc; cat $X/passwd}, base64-decoded + * paths) can still slip through. The agent is not expected to produce + * such forms in normal use, but a fully adversarial caller would need a + * real process sandbox (sandbox-exec / firejail / bwrap) on top of this + * check. + * + * @param command the shell command line as it will be passed to {@code sh -c} + * @throws IllegalArgumentException when the command references a location + * outside the workspace boundary + */ + public static void validateShellCommand(String command) { + validateShellCommand(command, null); + } + + /** ToolContext-aware overload — see {@link #validateShellCommand(String)}. */ + public static void validateShellCommand(String command, @Nullable ToolContext ctx) { + if (command == null || command.isEmpty()) return; + String basePath = resolveBasePath(ctx); + if (basePath == null || basePath.isBlank()) return; + Path root = Paths.get(basePath).toAbsolutePath().normalize(); + + // 1. Tilde — expands to $HOME, always outside a non-$HOME workspace. + if (TILDE_REF.matcher(command).find()) { + throw new IllegalArgumentException( + "Shell command uses tilde (~) expansion which resolves outside the workspace boundary: " + + truncateForError(command)); + } + + // 2. Env-var refs to locations that typically resolve outside the workspace. + Matcher envMatch = OUTSIDE_ENV_VAR.matcher(command); + if (envMatch.find()) { + throw new IllegalArgumentException( + "Shell command references environment variable " + envMatch.group() + + " which may resolve outside the workspace boundary"); + } + + // 3. Absolute-path tokens, including those nested inside $(...) or `...`. + Matcher pathMatch = ABS_PATH_TOKEN.matcher(command); + while (pathMatch.find()) { + String candidate = pathMatch.group(1); + // Strip trailing punctuation that the shell would treat as a separator + // but the regex captured into the path (defensive trim — the character + // class excludes most, this catches edge cases like a path followed + // by a comma in a sentence). + while (candidate.length() > 1) { + char tail = candidate.charAt(candidate.length() - 1); + if (tail == ',' || tail == ':' || tail == '.' || tail == ')' || tail == ']') { + candidate = candidate.substring(0, candidate.length() - 1); + } else { + break; + } + } + Path normalized; + try { + normalized = Paths.get(candidate).normalize(); + } catch (Exception ex) { + // Unparseable as a path — leave it alone, not our concern. + continue; + } + if (!normalized.startsWith(root)) { + throw new IllegalArgumentException( + "Shell command references path outside workspace boundary: " + + normalized + ", allowed root: " + root); + } + } + } + + /** + * Match absolute path tokens — a leading slash that starts a fresh token + * (preceded by start-of-string, whitespace, a shell separator, or an + * opening quote/parenthesis/backtick) and runs until the next shell + * separator or quote. The {@code (?(`\"'={}])(?()\"'`{}=]+)"); + + /** Bare tilde or tilde at the start of a path token: {@code ~}, {@code ~/foo}, {@code "~/bar"}. */ + private static final Pattern TILDE_REF = Pattern.compile( + "(?:^|[\\s|&;<>(`\"'={}])~(?=[/\\s|&;<>)`\"'$]|$)"); + + /** + * Env-var references that almost always point outside a project-scoped + * workspace. {@code $PATH} is on the list because writing to a directory + * on {@code $PATH} is a privilege-escalation vector. + */ + private static final Pattern OUTSIDE_ENV_VAR = Pattern.compile( + "\\$\\{?(HOME|USER|LOGNAME|TMPDIR|TMP|TEMP|PWD|OLDPWD|PATH|MAIL)\\b"); + + private static String truncateForError(String s) { + return s.length() > 200 ? s.substring(0, 200) + "..." : s; + } + /** * Resolve the active workspace base path. Order of preference: *

    diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java index ba2ea7b8..da8de88b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java @@ -449,7 +449,7 @@ public class WikiRawMaterialService { // 二进制文件:调用 DocumentExtractTool 提取 if (entity.getSourcePath() != null && !entity.getSourcePath().isBlank()) { try { - String result = documentExtractTool.extract_document_text(entity.getSourcePath(), null); + String result = documentExtractTool.extract_document_text(entity.getSourcePath(), null, null); JSONObject json = JSONUtil.parseObj(result); if (json.getBool("success", false)) { String text = json.getStr("text"); diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java new file mode 100644 index 00000000..70c42fbd --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java @@ -0,0 +1,175 @@ +package vip.mate.tool.guard; + +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 vip.mate.tool.builtin.ToolExecutionContext; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Exercises {@link WorkspacePathGuard#validateShellCommand(String)} — the + * static command-string scan that backstops {@code ShellExecuteTool} so an + * absolute-path reference in the shell command cannot reach outside the + * configured workspace boundary, even though the shell process itself has + * full filesystem permissions. + * + *

    The boundary is read via {@link ToolExecutionContext#workspaceBasePath()}. + */ +@DisabledOnOs(OS.WINDOWS) // POSIX-style absolute paths in these cases +class WorkspacePathGuardShellTest { + + private static final String WORKSPACE = "/tmp/ws-guard-shell-test"; + + @BeforeEach + void setup() { + ToolExecutionContext.set("conv-test", "test-user", WORKSPACE); + } + + @AfterEach + void teardown() { + ToolExecutionContext.clear(); + } + + // ==================== No-op when sandbox absent ==================== + + @Test + @DisplayName("No workspace configured → all commands pass") + void noWorkspace_noop() { + ToolExecutionContext.clear(); + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand("cat /etc/passwd")); + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand("rm -rf /")); + } + + @Test + @DisplayName("Null or empty command → no-op") + void nullOrEmpty_noop() { + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand(null)); + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand("")); + } + + // ==================== In-boundary commands pass ==================== + + @Test + @DisplayName("Relative paths and in-workspace absolute paths pass") + void inBoundary_pass() { + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand("ls -la")); + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand("cat foo.txt")); + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand("cat subdir/bar.txt")); + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand( + "cat " + WORKSPACE + "/foo.txt")); + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand( + "cd " + WORKSPACE + "/subdir && ls")); + } + + @Test + @DisplayName("URLs are not mistaken for filesystem paths") + void urls_pass() { + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand( + "curl -s https://example.com/api/data")); + assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand( + "wget -O out.txt http://host:8080/path/to/file")); + } + + // ==================== Out-of-boundary absolute paths blocked ==================== + + @Test + @DisplayName("Absolute path outside workspace → rejected") + void absoluteOutside_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat /etc/passwd")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("head -3 /Users/someone/code/secret.md")); + } + + @Test + @DisplayName("Output redirection to outside path → rejected") + void redirection_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("echo evil > /tmp/leak.txt")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("ls >> /var/log/sneak.log")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("grep foo < /etc/hosts")); + } + + @Test + @DisplayName("cd / pushd to outside path → rejected") + void cdOutside_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cd /etc && ls")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("pushd /var/spool")); + } + + @Test + @DisplayName("ln -s to an outside target → rejected") + void symlinkCreate_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("ln -sf /etc/passwd alias")); + } + + @Test + @DisplayName("Pipe with outside path on either side → rejected") + void pipeOutside_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat /etc/passwd | grep root")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("env | tee /tmp/dump.txt")); + } + + @Test + @DisplayName("Quoted absolute path → rejected") + void quotedAbsolute_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat \"/etc/passwd\"")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat '/etc/passwd'")); + } + + @Test + @DisplayName("Command substitution $(...) with outside path → rejected") + void commandSubstOutside_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("echo $(cat /etc/passwd)")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("X=`head /etc/hostname`; echo $X")); + } + + // ==================== Tilde + env-var rejection ==================== + + @Test + @DisplayName("Tilde expansion → rejected") + void tilde_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat ~/.zshrc")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cd ~ && ls")); + } + + @Test + @DisplayName("$HOME / ${HOME} / $TMPDIR / $PATH → rejected") + void envVarOutside_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat $HOME/.zshrc")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("ls ${HOME}/Documents")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("touch $TMPDIR/leak")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("echo bad > $PATH/evil")); + } + + @Test + @DisplayName("Other env vars not on the deny list are allowed") + void unrelatedEnvVar_pass() { + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("echo $LANG")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("printf '%s\\n' \"$MY_FLAG\"")); + } +}