fix(tool-guard): enforce workspace boundary for execute_code and trust spill roots (#403)

execute_code (bash/sh/shell) bypassed the workspace boundary guard, so shell
code run through it could read/write/delete paths outside the workspace sandbox
(e.g. cat /etc/passwd) while the same paths were blocked for read_file and the
shell tools. Bring execute_code under the guard (scan only shell-language code,
report the code param), and trust the tool-result spill roots so a legitimate
spilled result stays readable. Adds regression tests.
This commit is contained in:
matevip 2026-06-23 10:11:54 +08:00
parent 5ff58b00ad
commit 252a6fc425
5 changed files with 291 additions and 7 deletions

View File

@ -1,8 +1,10 @@
package vip.mate.agent.graph.executor;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.context.StructuredTruncator;
import vip.mate.tool.guard.WorkspacePathGuard;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
@ -73,6 +75,30 @@ public class ToolResultStorage {
this.excludedToolsSnapshot = props.excludedToolsSet();
}
/**
* Trust the deterministic spill roots with the workspace path guard at
* startup, before any spill happens in this JVM. Without this, a
* conversation that spilled in a previous run and is then resumed after a
* restart would have its {@code read_file} of the still-on-disk spill path
* rejected as a boundary escape until the next spill re-registers the root.
* The per-workspace branch ({@code <workspace>/.mateclaw/tool-results}) is
* intentionally not registered here it already sits inside its own
* workspace boundary.
*/
@PostConstruct
void registerSpillRootsAsTrusted() {
if (!props.isEnabled()) {
return;
}
if (!props.getStorageBaseDir().isEmpty()) {
WorkspacePathGuard.addTrustedRoot(props.getStorageBaseDir());
}
String tmp = System.getProperty("java.io.tmpdir");
if (tmp != null && !tmp.isEmpty()) {
WorkspacePathGuard.addTrustedRoot(Paths.get(tmp, "mateclaw", "tool-results").toString());
}
}
/** D-6: current cumulative spill count (monotonically increasing). */
public long getSpillCount() {
return spillCount.get();
@ -283,18 +309,30 @@ public class ToolResultStorage {
private Path resolveBaseDir(String workspaceBasePath) {
Path base;
boolean outsideWorkspace;
if (!props.getStorageBaseDir().isEmpty()) {
base = Paths.get(props.getStorageBaseDir());
outsideWorkspace = true;
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
// Inside the workspace boundary already read_file of these spill
// files is permitted without an extra trusted-root registration.
base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
outsideWorkspace = false;
} else {
String tmp = System.getProperty("java.io.tmpdir");
if (tmp == null || tmp.isEmpty()) return null;
base = Paths.get(tmp, "mateclaw", "tool-results");
outsideWorkspace = true;
}
// Register so the retention sweep and conversation-delete hook can
// reach this root even when the workspace path is no longer in scope.
observedRoots.add(base);
// A spill directory that lives outside the workspace must be trusted by
// the path guard; otherwise the read_file the spill preview tells the
// agent to perform is rejected as a workspace-boundary escape.
if (outsideWorkspace) {
WorkspacePathGuard.addTrustedRoot(base.toString());
}
return base;
}

View File

@ -87,12 +87,84 @@ public final class WorkspacePathGuard {
return defaultRoot;
}
/**
* Additional always-trusted roots that sit <em>outside</em> any workspace
* boundary yet must remain readable by the agent. The tool-result spill
* store registers its base directories here: when a tool produces an
* oversized result it is written to disk and the agent is handed back a
* path with the instruction to {@code read_file} it on demand. That spill
* directory may live outside the workspace (a central
* {@code storage-base-dir} or the {@code ${java.io.tmpdir}} fallback), so
* without this allow-list the very read the agent is told to perform would
* be rejected as a boundary escape. Registered roots are matched exactly
* like {@link #skillRoot} by {@code startsWith} on the normalized path.
*/
private static final Set<Path> trustedRoots = java.util.concurrent.ConcurrentHashMap.newKeySet();
/**
* Register an additional always-trusted root (e.g. a tool-result spill
* directory). A {@code null} or blank path is ignored. Idempotent.
*/
public static void addTrustedRoot(@Nullable String path) {
if (path == null || path.isBlank()) {
return;
}
Path normalized = Paths.get(path).toAbsolutePath().normalize();
if (trustedRoots.add(normalized)) {
log.info("[WorkspacePathGuard] Trusted root added: {}", normalized);
}
}
/** Clear every registered trusted root. Intended for test teardown. */
public static void clearTrustedRoots() {
trustedRoots.clear();
}
/** True when {@code normalized} lives under the shared skill root (if one is set). */
private static boolean isUnderSkillRoot(Path normalized) {
Path sr = skillRoot;
return sr != null && normalized.startsWith(sr);
}
/**
* True when {@code normalized} (or its symlink-resolved real path) lives
* under the shared skill root or any registered {@link #trustedRoots}.
* Bundles the skill-root and trusted-root checks so every boundary check
* site stays a single call.
*/
private static boolean isExempt(Path normalized) {
if (isUnderSkillRoot(normalized)) {
return true;
}
for (Path root : trustedRoots) {
if (normalized.startsWith(root)) {
return true;
}
}
return false;
}
/** Symlink-resolved variant of {@link #isExempt}. */
private static boolean isExemptReal(Path realPath) {
if (isUnderSkillRootReal(realPath)) {
return true;
}
for (Path root : trustedRoots) {
if (realPath.startsWith(root)) {
return true;
}
try {
Path realRoot = root.toFile().exists() ? root.toRealPath() : root;
if (realPath.startsWith(realRoot)) {
return true;
}
} catch (IOException e) {
// fall through the plain startsWith above already ran
}
}
return false;
}
/**
* Symlink-resolved variant of {@link #isUnderSkillRoot}. Resolves the skill
* root's real path so a path whose real location lands inside the skill
@ -142,7 +214,7 @@ public final class WorkspacePathGuard {
Path root = Paths.get(basePath).toAbsolutePath().normalize();
// 先用 normalize 检查再尝试 toRealPath 防符号链接逃逸
if (!normalized.startsWith(root) && !isUnderSkillRoot(normalized)) {
if (!normalized.startsWith(root) && !isExempt(normalized)) {
throw new IllegalArgumentException(
"Path is outside workspace boundary: " + normalized + ", allowed root: " + root);
}
@ -152,7 +224,7 @@ public final class WorkspacePathGuard {
if (normalized.toFile().exists()) {
Path realPath = normalized.toRealPath();
Path realRoot = root.toFile().exists() ? root.toRealPath() : root;
if (!realPath.startsWith(realRoot) && !isUnderSkillRootReal(realPath)) {
if (!realPath.startsWith(realRoot) && !isExemptReal(realPath)) {
throw new IllegalArgumentException(
"Path escapes workspace via symlink: " + realPath + ", allowed root: " + realRoot);
}
@ -262,7 +334,7 @@ public final class WorkspacePathGuard {
Path root = basePathToRoot(basePath);
if (root == null) return null;
Path normalized = Paths.get(rawPath).toAbsolutePath().normalize();
if (!normalized.startsWith(root) && !isUnderSkillRoot(normalized)) {
if (!normalized.startsWith(root) && !isExempt(normalized)) {
return "Path is outside workspace boundary: " + normalized + ", allowed root: " + root;
}
return null;
@ -340,7 +412,7 @@ public final class WorkspacePathGuard {
if (destructive && normalized.equals(root)) {
throw rootDeletionError(root);
}
if (!normalized.startsWith(root) && !isUnderSkillRoot(normalized)) {
if (!normalized.startsWith(root) && !isExempt(normalized)) {
throw new IllegalArgumentException(
"Shell command references path outside workspace boundary: "
+ normalized + ", allowed root: " + root);
@ -364,7 +436,7 @@ public final class WorkspacePathGuard {
if (destructive && resolved.equals(root)) {
throw rootDeletionError(root);
}
if (!resolved.startsWith(root) && !isUnderSkillRoot(resolved)) {
if (!resolved.startsWith(root) && !isExempt(resolved)) {
throw new IllegalArgumentException(
"Shell command uses parent-directory traversal that escapes the workspace: '"
+ candidate + "' would resolve to " + resolved

View File

@ -8,6 +8,7 @@ import vip.mate.tool.guard.WorkspacePathGuard;
import vip.mate.tool.guard.model.*;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@ -36,6 +37,25 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
"execute_shell_command", "shell_execute", "run_command"
);
/**
* Inline code-execution tool. Its shell content lives in the {@code code}
* JSON parameter (selected by a sibling {@code language} parameter), not in
* a {@code command} parameter, so it needs its own extraction path. Only
* shell-language code is screened for boundary escapes see
* {@link #SHELL_LANGUAGES} and {@link #evaluate}.
*/
private static final String CODE_TOOL_NAME = "execute_code";
/**
* {@code language} values whose {@code code} is shell script and can be
* scanned with the shell-syntax boundary scanner. Mirrors the {@code .sh}
* aliases accepted by the code-execution runtime. Python/Node code is not
* scanned: the shell scanner would false-positive on absolute-path string
* literals while missing interpreter-specific file access, so applying it
* there is both noisy and incomplete.
*/
private static final Set<String> SHELL_LANGUAGES = Set.of("bash", "sh", "shell");
/** File tools and their JSON path-parameter name. */
private static final Map<String, String> FILE_PATH_PARAMS = Map.of(
"read_file", "filePath",
@ -48,7 +68,9 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
@Override
public boolean supports(ToolInvocationContext context) {
String tool = context.toolName();
return tool != null && (SHELL_TOOL_NAMES.contains(tool) || FILE_PATH_PARAMS.containsKey(tool));
return tool != null && (SHELL_TOOL_NAMES.contains(tool)
|| CODE_TOOL_NAME.equals(tool)
|| FILE_PATH_PARAMS.containsKey(tool));
}
/** Run before the DB-rule guardians so a boundary escape blocks early. */
@ -76,6 +98,26 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
return List.of();
}
if (CODE_TOOL_NAME.equals(tool)) {
// The shell content lives in the `code` param, gated by `language`.
// Scan only shell-language code, and extract `code` explicitly
// rather than scanning the whole rawArgs JSON the latter would
// false-positive on Python/Node source that merely contains an
// absolute-path string literal.
if (!isShellLanguage(extractJsonParam(rawArgs, "language"))) {
return List.of();
}
String code = extractJsonParam(rawArgs, "code");
if (code == null) {
return List.of();
}
String violation = WorkspacePathGuard.findShellBoundaryViolation(code, basePath);
if (violation != null) {
return List.of(boundaryFinding(tool, "code", code, violation));
}
return List.of();
}
String paramName = FILE_PATH_PARAMS.get(tool);
if (paramName != null) {
String path = extractJsonParam(rawArgs, paramName);
@ -102,6 +144,11 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
GuardDecision.BLOCK);
}
/** True when {@code language} names a shell interpreter (bash/sh/shell). */
private static boolean isShellLanguage(String language) {
return language != null && SHELL_LANGUAGES.contains(language.trim().toLowerCase(Locale.ROOT));
}
private String extractJsonParam(String rawArgs, String paramName) {
try {
Map<String, Object> params = objectMapper.readValue(rawArgs, new TypeReference<>() {});

View File

@ -38,6 +38,7 @@ class WorkspacePathGuardSandboxTest {
ToolExecutionContext.clear();
WorkspacePathGuard.setDefaultRoot(null);
WorkspacePathGuard.setSkillRoot(null);
WorkspacePathGuard.clearTrustedRoots();
}
// ==================== Defect 1: fail-closed default root ====================
@ -117,6 +118,61 @@ class WorkspacePathGuardSandboxTest {
}
}
// ============ Tool-result spill dir is trusted outside the boundary (issue #403) ============
@Nested
@DisplayName("A registered tool-result spill root is readable outside the workspace boundary")
class TrustedSpillRoot {
private static final String SPILL_ROOT = "/tmp/mate-tool-result-spill/tool-results";
@BeforeEach
void setup() {
// A conversation bound to a workspace, with a central spill dir that
// lives outside that workspace the production scenario from #403.
ToolExecutionContext.set("conv", "user", WORKSPACE);
WorkspacePathGuard.addTrustedRoot(SPILL_ROOT);
}
@Test
@DisplayName("validatePath: reading a spilled tool result outside the workspace is allowed")
void validatePathSpill_pass() {
assertDoesNotThrow(() ->
WorkspacePathGuard.validatePath(SPILL_ROOT + "/conv/call_2.txt"));
}
@Test
@DisplayName("findPathBoundaryViolation: spill path reports no violation")
void findPathBoundaryViolationSpill_null() {
org.junit.jupiter.api.Assertions.assertNull(
WorkspacePathGuard.findPathBoundaryViolation(
SPILL_ROOT + "/conv/call_2.txt", WORKSPACE));
}
@Test
@DisplayName("Shell: cat-ing a spilled tool result outside the workspace is allowed")
void shellSpill_pass() {
assertDoesNotThrow(() ->
WorkspacePathGuard.validateShellCommand("cat " + SPILL_ROOT + "/conv/call_2.txt"));
}
@Test
@DisplayName("A non-spill path outside the workspace is still blocked")
void unrelatedOutside_stillBlocked() {
assertThrows(IllegalArgumentException.class, () ->
WorkspacePathGuard.validatePath("/etc/passwd"));
assertThrows(IllegalArgumentException.class, () ->
WorkspacePathGuard.validateShellCommand("cat /etc/passwd"));
}
@Test
@DisplayName("Deleting inside the trusted spill root is not a root-deletion escape")
void deleteInsideSpillRoot_pass() {
assertDoesNotThrow(() ->
WorkspacePathGuard.validateShellCommand("rm -rf " + SPILL_ROOT + "/conv"));
}
}
// ==================== Defect 2: workspace-root deletion guard ====================
@Nested

View File

@ -47,6 +47,13 @@ class WorkspaceBoundaryGuardianTest {
.withWorkspaceBasePath(basePath);
}
private ToolInvocationContext code(String language, String src, String basePath) {
String args = "{\"language\":\"" + language + "\",\"code\":\""
+ src.replace("\"", "\\\"") + "\"}";
return ToolInvocationContext.of("execute_code", args, "conv", "agent")
.withWorkspaceBasePath(basePath);
}
private void assertBlocked(List<GuardFinding> findings) {
assertFalse(findings.isEmpty(), "expected a boundary finding");
GuardFinding f = findings.get(0);
@ -79,6 +86,50 @@ class WorkspaceBoundaryGuardianTest {
assertTrue(guardian.evaluate(shell("rm -rf " + WORKSPACE + "/subdir", WORKSPACE)).isEmpty());
}
// ==================== Inline code execution (execute_code) ====================
@Test
@DisplayName("execute_code bash escaping the workspace → CRITICAL BLOCK finding")
void codeBashEscape_blocked() {
// The #403 reproduction: `cat /tmp/...` and reading /etc/passwd from
// shell-language code must be blocked just like the shell tool.
assertBlocked(guardian.evaluate(code("bash", "cat /etc/passwd", WORKSPACE)));
assertBlocked(guardian.evaluate(code("sh", "cat /tmp/mate-tool-result-spill/x", WORKSPACE)));
assertBlocked(guardian.evaluate(code("shell", "ls ..", WORKSPACE)));
}
@Test
@DisplayName("execute_code bash deleting the workspace root → CRITICAL BLOCK finding")
void codeBashRootDeletion_blocked() {
assertBlocked(guardian.evaluate(code("bash", "rm -rf " + WORKSPACE, WORKSPACE)));
}
@Test
@DisplayName("execute_code bash inside the workspace → no finding")
void codeBashInBounds_pass() {
assertTrue(guardian.evaluate(code("bash", "ls -la", WORKSPACE)).isEmpty());
assertTrue(guardian.evaluate(code("bash", "cat " + WORKSPACE + "/foo.txt", WORKSPACE)).isEmpty());
}
@Test
@DisplayName("execute_code reports the violating param as 'code'")
void codeViolation_paramName() {
List<GuardFinding> findings = guardian.evaluate(code("bash", "cat /etc/passwd", WORKSPACE));
assertFalse(findings.isEmpty());
assertEquals("code", findings.get(0).paramName());
}
@Test
@DisplayName("execute_code python/node is not path-scanned (avoids string-literal false positives)")
void codeNonShell_notScanned() {
// A Python/Node literal containing an absolute path must NOT be treated
// as a shell boundary escape the static shell scan doesn't apply.
assertTrue(guardian.evaluate(code("python", "open('/etc/passwd')", WORKSPACE)).isEmpty());
assertTrue(guardian.evaluate(code("node", "fs.readFileSync('/etc/passwd')", WORKSPACE)).isEmpty());
// Unknown / missing language is likewise not scanned.
assertTrue(guardian.evaluate(code("ruby", "File.read('/etc/passwd')", WORKSPACE)).isEmpty());
}
// ==================== File path tools ====================
@Test
@ -112,11 +163,31 @@ class WorkspaceBoundaryGuardianTest {
}
@Test
@DisplayName("supports() only fires for shell and file-path tools")
@DisplayName("supports() fires for shell, code, and file-path tools")
void supports_scope() {
assertTrue(guardian.supports(shell("ls", WORKSPACE)));
assertTrue(guardian.supports(code("bash", "ls", WORKSPACE)));
assertTrue(guardian.supports(write("a.txt", WORKSPACE)));
assertFalse(guardian.supports(
ToolInvocationContext.of("web_search", "{}", "conv", "agent")));
}
// ==================== Tool-result spill dir stays reachable (issue #403) ====================
@Test
@DisplayName("execute_code can still cat a legitimate spilled tool result outside the workspace")
void codeBashSpill_pass() {
String spillRoot = "/tmp/mate-tool-result-spill/tool-results";
WorkspacePathGuard.addTrustedRoot(spillRoot);
try {
// The trusted-root mechanism added for #403 must keep working once
// execute_code is brought under the boundary guard: reading a real
// spill path is allowed, an unrelated outside path is still blocked.
assertTrue(guardian.evaluate(
code("bash", "cat " + spillRoot + "/conv/call_2.txt", WORKSPACE)).isEmpty());
assertBlocked(guardian.evaluate(code("bash", "cat /etc/passwd", WORKSPACE)));
} finally {
WorkspacePathGuard.clearTrustedRoots();
}
}
}