mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
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:
parent
5ff58b00ad
commit
252a6fc425
@ -1,8 +1,10 @@
|
|||||||
package vip.mate.agent.graph.executor;
|
package vip.mate.agent.graph.executor;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
import vip.mate.agent.context.StructuredTruncator;
|
import vip.mate.agent.context.StructuredTruncator;
|
||||||
|
import vip.mate.tool.guard.WorkspacePathGuard;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@ -73,6 +75,30 @@ public class ToolResultStorage {
|
|||||||
this.excludedToolsSnapshot = props.excludedToolsSet();
|
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). */
|
/** D-6: current cumulative spill count (monotonically increasing). */
|
||||||
public long getSpillCount() {
|
public long getSpillCount() {
|
||||||
return spillCount.get();
|
return spillCount.get();
|
||||||
@ -283,18 +309,30 @@ public class ToolResultStorage {
|
|||||||
|
|
||||||
private Path resolveBaseDir(String workspaceBasePath) {
|
private Path resolveBaseDir(String workspaceBasePath) {
|
||||||
Path base;
|
Path base;
|
||||||
|
boolean outsideWorkspace;
|
||||||
if (!props.getStorageBaseDir().isEmpty()) {
|
if (!props.getStorageBaseDir().isEmpty()) {
|
||||||
base = Paths.get(props.getStorageBaseDir());
|
base = Paths.get(props.getStorageBaseDir());
|
||||||
|
outsideWorkspace = true;
|
||||||
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
|
} 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");
|
base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
|
||||||
|
outsideWorkspace = false;
|
||||||
} else {
|
} else {
|
||||||
String tmp = System.getProperty("java.io.tmpdir");
|
String tmp = System.getProperty("java.io.tmpdir");
|
||||||
if (tmp == null || tmp.isEmpty()) return null;
|
if (tmp == null || tmp.isEmpty()) return null;
|
||||||
base = Paths.get(tmp, "mateclaw", "tool-results");
|
base = Paths.get(tmp, "mateclaw", "tool-results");
|
||||||
|
outsideWorkspace = true;
|
||||||
}
|
}
|
||||||
// Register so the retention sweep and conversation-delete hook can
|
// Register so the retention sweep and conversation-delete hook can
|
||||||
// reach this root even when the workspace path is no longer in scope.
|
// reach this root even when the workspace path is no longer in scope.
|
||||||
observedRoots.add(base);
|
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;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -87,12 +87,84 @@ public final class WorkspacePathGuard {
|
|||||||
return defaultRoot;
|
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). */
|
/** True when {@code normalized} lives under the shared skill root (if one is set). */
|
||||||
private static boolean isUnderSkillRoot(Path normalized) {
|
private static boolean isUnderSkillRoot(Path normalized) {
|
||||||
Path sr = skillRoot;
|
Path sr = skillRoot;
|
||||||
return sr != null && normalized.startsWith(sr);
|
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
|
* Symlink-resolved variant of {@link #isUnderSkillRoot}. Resolves the skill
|
||||||
* root's real path so a path whose real location lands inside 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();
|
Path root = Paths.get(basePath).toAbsolutePath().normalize();
|
||||||
|
|
||||||
// 先用 normalize 检查,再尝试 toRealPath 防符号链接逃逸
|
// 先用 normalize 检查,再尝试 toRealPath 防符号链接逃逸
|
||||||
if (!normalized.startsWith(root) && !isUnderSkillRoot(normalized)) {
|
if (!normalized.startsWith(root) && !isExempt(normalized)) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"Path is outside workspace boundary: " + normalized + ", allowed root: " + root);
|
"Path is outside workspace boundary: " + normalized + ", allowed root: " + root);
|
||||||
}
|
}
|
||||||
@ -152,7 +224,7 @@ public final class WorkspacePathGuard {
|
|||||||
if (normalized.toFile().exists()) {
|
if (normalized.toFile().exists()) {
|
||||||
Path realPath = normalized.toRealPath();
|
Path realPath = normalized.toRealPath();
|
||||||
Path realRoot = root.toFile().exists() ? root.toRealPath() : root;
|
Path realRoot = root.toFile().exists() ? root.toRealPath() : root;
|
||||||
if (!realPath.startsWith(realRoot) && !isUnderSkillRootReal(realPath)) {
|
if (!realPath.startsWith(realRoot) && !isExemptReal(realPath)) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"Path escapes workspace via symlink: " + realPath + ", allowed root: " + realRoot);
|
"Path escapes workspace via symlink: " + realPath + ", allowed root: " + realRoot);
|
||||||
}
|
}
|
||||||
@ -262,7 +334,7 @@ public final class WorkspacePathGuard {
|
|||||||
Path root = basePathToRoot(basePath);
|
Path root = basePathToRoot(basePath);
|
||||||
if (root == null) return null;
|
if (root == null) return null;
|
||||||
Path normalized = Paths.get(rawPath).toAbsolutePath().normalize();
|
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 "Path is outside workspace boundary: " + normalized + ", allowed root: " + root;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -340,7 +412,7 @@ public final class WorkspacePathGuard {
|
|||||||
if (destructive && normalized.equals(root)) {
|
if (destructive && normalized.equals(root)) {
|
||||||
throw rootDeletionError(root);
|
throw rootDeletionError(root);
|
||||||
}
|
}
|
||||||
if (!normalized.startsWith(root) && !isUnderSkillRoot(normalized)) {
|
if (!normalized.startsWith(root) && !isExempt(normalized)) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"Shell command references path outside workspace boundary: "
|
"Shell command references path outside workspace boundary: "
|
||||||
+ normalized + ", allowed root: " + root);
|
+ normalized + ", allowed root: " + root);
|
||||||
@ -364,7 +436,7 @@ public final class WorkspacePathGuard {
|
|||||||
if (destructive && resolved.equals(root)) {
|
if (destructive && resolved.equals(root)) {
|
||||||
throw rootDeletionError(root);
|
throw rootDeletionError(root);
|
||||||
}
|
}
|
||||||
if (!resolved.startsWith(root) && !isUnderSkillRoot(resolved)) {
|
if (!resolved.startsWith(root) && !isExempt(resolved)) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"Shell command uses parent-directory traversal that escapes the workspace: '"
|
"Shell command uses parent-directory traversal that escapes the workspace: '"
|
||||||
+ candidate + "' would resolve to " + resolved
|
+ candidate + "' would resolve to " + resolved
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import vip.mate.tool.guard.WorkspacePathGuard;
|
|||||||
import vip.mate.tool.guard.model.*;
|
import vip.mate.tool.guard.model.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@ -36,6 +37,25 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
|
|||||||
"execute_shell_command", "shell_execute", "run_command"
|
"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. */
|
/** File tools and their JSON path-parameter name. */
|
||||||
private static final Map<String, String> FILE_PATH_PARAMS = Map.of(
|
private static final Map<String, String> FILE_PATH_PARAMS = Map.of(
|
||||||
"read_file", "filePath",
|
"read_file", "filePath",
|
||||||
@ -48,7 +68,9 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
|
|||||||
@Override
|
@Override
|
||||||
public boolean supports(ToolInvocationContext context) {
|
public boolean supports(ToolInvocationContext context) {
|
||||||
String tool = context.toolName();
|
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. */
|
/** Run before the DB-rule guardians so a boundary escape blocks early. */
|
||||||
@ -76,6 +98,26 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
|
|||||||
return List.of();
|
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);
|
String paramName = FILE_PATH_PARAMS.get(tool);
|
||||||
if (paramName != null) {
|
if (paramName != null) {
|
||||||
String path = extractJsonParam(rawArgs, paramName);
|
String path = extractJsonParam(rawArgs, paramName);
|
||||||
@ -102,6 +144,11 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian {
|
|||||||
GuardDecision.BLOCK);
|
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) {
|
private String extractJsonParam(String rawArgs, String paramName) {
|
||||||
try {
|
try {
|
||||||
Map<String, Object> params = objectMapper.readValue(rawArgs, new TypeReference<>() {});
|
Map<String, Object> params = objectMapper.readValue(rawArgs, new TypeReference<>() {});
|
||||||
|
|||||||
@ -38,6 +38,7 @@ class WorkspacePathGuardSandboxTest {
|
|||||||
ToolExecutionContext.clear();
|
ToolExecutionContext.clear();
|
||||||
WorkspacePathGuard.setDefaultRoot(null);
|
WorkspacePathGuard.setDefaultRoot(null);
|
||||||
WorkspacePathGuard.setSkillRoot(null);
|
WorkspacePathGuard.setSkillRoot(null);
|
||||||
|
WorkspacePathGuard.clearTrustedRoots();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Defect 1: fail-closed default root ====================
|
// ==================== 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 ====================
|
// ==================== Defect 2: workspace-root deletion guard ====================
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
|
|||||||
@ -47,6 +47,13 @@ class WorkspaceBoundaryGuardianTest {
|
|||||||
.withWorkspaceBasePath(basePath);
|
.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) {
|
private void assertBlocked(List<GuardFinding> findings) {
|
||||||
assertFalse(findings.isEmpty(), "expected a boundary finding");
|
assertFalse(findings.isEmpty(), "expected a boundary finding");
|
||||||
GuardFinding f = findings.get(0);
|
GuardFinding f = findings.get(0);
|
||||||
@ -79,6 +86,50 @@ class WorkspaceBoundaryGuardianTest {
|
|||||||
assertTrue(guardian.evaluate(shell("rm -rf " + WORKSPACE + "/subdir", WORKSPACE)).isEmpty());
|
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 ====================
|
// ==================== File path tools ====================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -112,11 +163,31 @@ class WorkspaceBoundaryGuardianTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("supports() only fires for shell and file-path tools")
|
@DisplayName("supports() fires for shell, code, and file-path tools")
|
||||||
void supports_scope() {
|
void supports_scope() {
|
||||||
assertTrue(guardian.supports(shell("ls", WORKSPACE)));
|
assertTrue(guardian.supports(shell("ls", WORKSPACE)));
|
||||||
|
assertTrue(guardian.supports(code("bash", "ls", WORKSPACE)));
|
||||||
assertTrue(guardian.supports(write("a.txt", WORKSPACE)));
|
assertTrue(guardian.supports(write("a.txt", WORKSPACE)));
|
||||||
assertFalse(guardian.supports(
|
assertFalse(guardian.supports(
|
||||||
ToolInvocationContext.of("web_search", "{}", "conv", "agent")));
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user