fix(tool): enforce workspace boundary on shell commands and file metadata tools

This commit is contained in:
matevip 2026-05-25 16:26:16 +08:00
parent 9e9a96f674
commit b09a220ec7
6 changed files with 355 additions and 12 deletions

View File

@ -3,14 +3,15 @@ package vip.mate.tool.builtin;
import cn.hutool.json.JSONObject; import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j; 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.Tool;
import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
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;
@ -69,14 +70,28 @@ public class DocumentExtractTool {
""") """)
public String extract_document_text( public String extract_document_text(
@ToolParam(description = "文件的绝对路径或相对路径") String filePath, @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(); 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 = 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)) { 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
@ -185,10 +200,11 @@ public class DocumentExtractTool {
""") """)
public String extract_pdf_text( public String extract_pdf_text(
@ToolParam(description = "PDF 文件的绝对路径或相对路径") String filePath, @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; String options = pages != null ? "{\"pages\": \"" + pages + "\"}" : null;
return extract_document_text(filePath, options); return extract_document_text(filePath, options, ctx);
} }
@Tool(description = """ @Tool(description = """
@ -201,8 +217,9 @@ public class DocumentExtractTool {
支持 .docx .doc 格式 支持 .docx .doc 格式
""") """)
public String extract_docx_text( public String extract_docx_text(
@ToolParam(description = "Word 文档的绝对路径或相对路径") String filePath) { @ToolParam(description = "Word 文档的绝对路径或相对路径") String filePath,
return extract_document_text(filePath, null); @Nullable ToolContext ctx) {
return extract_document_text(filePath, null, ctx);
} }
// ==================== PDF 提取链 ==================== // ==================== PDF 提取链 ====================

View File

@ -3,8 +3,10 @@ package vip.mate.tool.builtin;
import cn.hutool.json.JSONObject; import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j; 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.Tool;
import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.BufferedReader; import java.io.BufferedReader;
@ -12,7 +14,6 @@ import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
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.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
@ -41,13 +42,28 @@ public class FileTypeDetectorTool {
注意对于 .docx/.pdf 等文档不会返回 read_file而是 extract_document_text 注意对于 .docx/.pdf 等文档不会返回 read_file而是 extract_document_text
""") """)
public String detect_file_type( 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(); JSONObject result = new JSONObject();
result.set("filePath", filePath); result.set("filePath", filePath);
try { 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)) { if (!Files.exists(path)) {
// Fall back to chat-upload basename matching for filenames that were // Fall back to chat-upload basename matching for filenames that were

View File

@ -3,8 +3,10 @@ package vip.mate.tool.builtin;
import cn.hutool.json.JSONObject; import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j; 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.Tool;
import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.IOException; import java.io.IOException;
@ -51,7 +53,10 @@ public class ShellExecuteTool {
+ "Dangerous operations trigger security approval. Returns structured result with exitCode, stdout, stderr, timedOut.") + "Dangerous operations trigger security approval. Returns structured result with exitCode, stdout, stderr, timedOut.")
public String execute_shell_command( public String execute_shell_command(
@ToolParam(description = "Shell command to execute") String 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; int timeout = (timeoutSeconds != null && timeoutSeconds > 0) ? timeoutSeconds : DEFAULT_TIMEOUT_SECONDS;
// 硬上限不允许超过 300 // 硬上限不允许超过 300
@ -63,6 +68,21 @@ public class ShellExecuteTool {
JSONObject result = new JSONObject(); JSONObject result = new JSONObject();
result.set("command", command); 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 stdoutFile = null;
Path stderrFile = null; Path stderrFile = null;

View File

@ -9,6 +9,8 @@ import vip.mate.tool.builtin.ToolExecutionContext;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; 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(); 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).
*
* <p>The check is a static scan of the literal command string. It rejects:
* <ul>
* <li>any absolute path token (e.g. {@code /etc/passwd}, {@code >/tmp/x},
* {@code cd /var}) whose normalized form is not under the workspace
* root even when nested inside command substitution {@code $(...)}
* or backticks;</li>
* <li>tilde expansion ({@code ~}, {@code ~/...}) always resolves to
* {@code $HOME}, which sits outside the workspace;</li>
* <li>references to environment variables ({@code $HOME}, {@code ${USER}},
* {@code $TMPDIR}, etc.) that typically resolve outside the workspace.</li>
* </ul>
*
* <p><b>Limitations</b> 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 (?<!:)} lookbehind excludes the second
* slash of a URL protocol (e.g. {@code https://host/path}) so URLs aren't
* mistaken for filesystem paths.
*/
private static final Pattern ABS_PATH_TOKEN = Pattern.compile(
"(?:^|[\\s|&;<>(`\"'={}])(?<!:)(/[^\\s|&;<>()\"'`{}=]+)");
/** 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: * Resolve the active workspace base path. Order of preference:
* <ol> * <ol>

View File

@ -449,7 +449,7 @@ 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); String result = documentExtractTool.extract_document_text(entity.getSourcePath(), null, 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");

View File

@ -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.
*
* <p>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\""));
}
}