diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java index 1d4f5d7d..cb713d39 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java @@ -87,43 +87,6 @@ public final class ChatUploadResolver { return null; } - /** - * Resolve a raw path against a pre-computed set of candidate upload roots - * (from {@link ChatUploadLocationResolver#resolveCandidateUploadRoots(String)}). - * Each root is the conversation-scoped upload directory - * ({@code {root}/{conversationId}/}); this overload avoids the - * {@code workspaceBasePath} heuristic so the guardian can use DB-resolved - * candidate roots even when the thread-local context carries {@code null}. - * - * @param rawPath user-supplied path (basename or relative) - * @param conversationId business conversation id - * @param candidateUploadRoots pre-resolved candidate upload roots, - * each being an upload root without - * the conversation-id subdirectory appended - * @return absolute path of the matched attachment, or {@code null} - */ - public static Path resolve(String rawPath, String conversationId, - List candidateUploadRoots) { - if (rawPath == null || rawPath.isBlank()) { - return null; - } - if (conversationId == null || conversationId.isBlank()) { - return null; - } - if (candidateUploadRoots == null || candidateUploadRoots.isEmpty()) { - return null; - } - for (Path uploadRoot : candidateUploadRoots) { - Path uploadDir = uploadRoot.resolve(conversationId) - .toAbsolutePath().normalize(); - Path matched = resolveIn(rawPath, uploadDir); - if (matched != null) { - return matched; - } - } - return null; - } - /** * Ordered candidate upload directories for a conversation: the * workspace-scoped dir first (when a base path is active), then the default 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 44d09400..bd971aab 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 @@ -432,10 +432,17 @@ public final class WorkspacePathGuard { continue; } if (isFilesystemRoot(normalized)) { - // A normalized filesystem root `/` (or `//`) is almost always a - // false positive from shell syntax — sed's s/pattern//, awk's - // empty field, etc. Real commands never target the filesystem - // root as a cat/ls/write operand. + // A token normalizing to the filesystem root is usually shell + // syntax misread as a path — sed's s/pattern//, awk's empty + // field, etc. — so it is skipped for non-destructive commands. + // When the command carries a destructive verb, fail closed: + // `rm -rf //` (or `/.`, `/..`) targets the filesystem root and + // must be refused. The verb flag is command-wide, so a compound + // command mixing e.g. `rm` with a sed empty replacement is also + // refused — the error tells the caller to split the command. + if (destructive) { + throw filesystemRootDeletionError(); + } continue; } if (destructive && normalized.equals(root)) { @@ -564,9 +571,10 @@ public final class WorkspacePathGuard { /** * True when {@code normalized} is the filesystem root ({@code /} or - * {@code //}). These are almost always false positives from shell syntax - * (sed's {@code s/pattern//}, awk's empty field, etc.) — real commands - * never target the filesystem root as a cat/ls/write operand. + * {@code //}). In non-destructive commands these are almost always false + * positives from shell syntax (sed's {@code s/pattern//}, awk's empty + * field, etc.) and are skipped; destructive commands are refused at the + * call site because {@code rm -rf //} really does target the root. */ private static boolean isFilesystemRoot(Path normalized) { String s = normalized.toString(); @@ -583,6 +591,14 @@ public final class WorkspacePathGuard { + ". Deleting the workspace root is refused — target a path inside it instead."); } + private static IllegalArgumentException filesystemRootDeletionError() { + return new IllegalArgumentException( + "Shell command combines a destructive verb (rm/rmdir/shred/srm) with a path that " + + "normalizes to the filesystem root (/), which is refused. If the root-like " + + "token comes from shell syntax (e.g. an empty sed replacement) rather than a " + + "delete target, run the delete and the text edit as separate commands."); + } + /** * Resolve the active workspace base path. Order of preference: *
    diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java index a232b950..5c0d333b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardian.java @@ -5,12 +5,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; -import vip.mate.tool.builtin.ChatUploadResolver; import vip.mate.tool.guard.WorkspacePathGuard; import vip.mate.tool.guard.model.*; import vip.mate.workspace.core.service.ChatUploadLocationResolver; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; import java.util.Locale; import java.util.Map; @@ -141,22 +141,20 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian { String path = extractJsonParam(rawArgs, paramName); String violation = WorkspacePathGuard.findPathBoundaryViolation(path, basePath); if (violation != null) { - // Chat-upload fallback: a path like "chat-uploads/{convId}/..." - // may sit outside the workspace root yet still be a legitimate - // user attachment. Resolve candidate upload roots from the DB - // (workspace-scoped + default) to find the file — this avoids - // the workspaceBasePath heuristic that can be null when the - // agent isn't configured with a workspace override. + // Chat-upload fallback: an attachment path may sit outside the + // workspace root yet still be a legitimate user upload. Resolve + // candidate upload roots from the DB (workspace-scoped + + // default) — this avoids the workspaceBasePath heuristic that + // can be null when the agent isn't configured with a workspace + // override. Any resolver failure keeps the BLOCK finding. String conversationId = context.conversationId(); if (conversationId != null && !conversationId.isBlank()) { try { List candidateRoots = chatUploadLocationResolver .resolveCandidateUploadRoots(conversationId); - Path resolved = ChatUploadResolver.resolve( - path, conversationId, candidateRoots); - if (resolved != null) { - log.debug("[WorkspaceBoundaryGuardian] Path {} resolved to chat-upload: {}", - path, resolved); + if (isInsideUploadDir(path, conversationId, candidateRoots)) { + log.debug("[WorkspaceBoundaryGuardian] Path {} is inside a chat-upload dir " + + "of conversation {}", path, conversationId); return List.of(); } } catch (Exception e) { @@ -170,6 +168,40 @@ public class WorkspaceBoundaryGuardian implements ToolGuardGuardian { return List.of(); } + /** + * True when {@code rawPath} itself normalizes to a location inside one of + * the conversation's candidate upload directories + * ({@code {root}/{conversationId}/}). + *

    + * The check is a strict prefix match on the requested path — a + * basename match against stored attachments is deliberately not enough to + * clear a boundary violation, because an arbitrary outside path could share + * a basename with an uploaded file and would then slip past the guard + * whenever it exists under some other trusted root. Tool-level resolvers + * may still redirect a basename-only request to the stored attachment; the + * redirected path they read lands inside the upload directory and passes + * this same check. + */ + private static boolean isInsideUploadDir(String rawPath, String conversationId, + List candidateRoots) { + if (rawPath == null || rawPath.isBlank() || candidateRoots == null) { + return false; + } + Path normalized; + try { + normalized = Paths.get(rawPath).toAbsolutePath().normalize(); + } catch (Exception e) { + return false; + } + for (Path root : candidateRoots) { + Path uploadDir = root.resolve(conversationId).toAbsolutePath().normalize(); + if (normalized.startsWith(uploadDir)) { + return true; + } + } + return false; + } + private GuardFinding boundaryFinding(String toolName, String paramName, String matchValue, String reason) { return new GuardFinding( "WORKSPACE_BOUNDARY_ESCAPE", 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 index 02a58a73..ad82b35c 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java @@ -311,6 +311,41 @@ class WorkspacePathGuardShellTest { WorkspacePathGuard.validateShellCommand("echo evil > /tmp/leak.txt")); } + // ==================== Filesystem-root tokens ==================== + + @Test + @DisplayName("Destructive command targeting the filesystem root (`rm -rf //`, `/.`, `/..`) → rejected") + void destructiveFilesystemRoot_blocked() { + // "//", "/." and "/.." all normalize to "/" — a delete aimed there must + // not be skipped by the root-token false-positive allowance. + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("rm -rf //")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("rm -rf /.")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("rm -rf /..")); + } + + @Test + @DisplayName("sed empty replacement (s/pattern//) is not misread as a filesystem-root path") + void sedEmptyReplacement_pass() { + // The trailing "// inside the sed script produces a token that + // normalizes to "/" — allowed because the command is non-destructive. + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("sed 's/\"text\": \"//' data.json")); + } + + @Test + @DisplayName("Compound command mixing a destructive verb with a root-normalizing token fails closed") + void destructiveMixedWithRootToken_blocked() { + // The destructive flag is command-wide by design: a compound command + // that both deletes and carries a "/"-normalizing token is refused, + // trading a rare false positive for never skipping `rm ... //`. + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand( + "rm -f old.log && sed 's/\"x\": \"//' data.json")); + } + // ==================== Device-node negative cases ==================== @Test diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java index d3f2c8aa..f86334dc 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/WorkspaceBoundaryGuardianTest.java @@ -5,17 +5,23 @@ 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 org.junit.jupiter.api.io.TempDir; import vip.mate.tool.guard.WorkspacePathGuard; import vip.mate.tool.guard.model.GuardDecision; import vip.mate.tool.guard.model.GuardFinding; import vip.mate.tool.guard.model.GuardSeverity; import vip.mate.tool.guard.model.ToolInvocationContext; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * Verifies that {@link WorkspaceBoundaryGuardian} turns a workspace-boundary @@ -47,6 +53,12 @@ class WorkspaceBoundaryGuardianTest { .withWorkspaceBasePath(basePath); } + private ToolInvocationContext read(String path, String basePath) { + String args = "{\"filePath\":\"" + path + "\"}"; + return ToolInvocationContext.of("read_file", args, "conv", "agent") + .withWorkspaceBasePath(basePath); + } + private ToolInvocationContext code(String language, String src, String basePath) { String args = "{\"language\":\"" + language + "\",\"code\":\"" + src.replace("\"", "\\\"") + "\"}"; @@ -194,6 +206,68 @@ class WorkspaceBoundaryGuardianTest { ToolInvocationContext.of("web_search", "{}", "conv", "agent"))); } + // ==================== Chat-upload fallback scope ==================== + + @Test + @DisplayName("A stored chat-upload path outside the workspace is allowed via the DB-backed fallback") + void chatUpload_storedPath_pass(@TempDir Path uploadRoot) throws Exception { + Path stored = Files.createDirectories(uploadRoot.resolve("conv")) + .resolve("1777391026594_secret.txt"); + Files.writeString(stored, "attachment"); + + ChatUploadLocationResolver resolver = mock(ChatUploadLocationResolver.class); + when(resolver.resolveCandidateUploadRoots("conv")).thenReturn(List.of(uploadRoot)); + WorkspaceBoundaryGuardian g = new WorkspaceBoundaryGuardian(resolver); + + // The upload root sits outside the workspace, so the boundary check + // trips first; the fallback must clear it for the real stored path. + assertTrue(g.evaluate(read(stored.toString(), WORKSPACE)).isEmpty()); + } + + @Test + @DisplayName("An outside path merely sharing a basename with an attachment stays blocked") + void chatUpload_basenameCollision_blocked(@TempDir Path uploadRoot, @TempDir Path elsewhere) + throws Exception { + // Store an attachment whose "{millis}_{safeName}" name would basename-match + // a request for "secret.txt" — the fallback must not let that clear a + // violation for a path pointing somewhere else entirely. + Path uploadDir = Files.createDirectories(uploadRoot.resolve("conv")); + Files.writeString(uploadDir.resolve("1777391026594_secret.txt"), "attachment"); + Path outside = elsewhere.resolve("secret.txt"); + Files.writeString(outside, "not an attachment"); + + ChatUploadLocationResolver resolver = mock(ChatUploadLocationResolver.class); + when(resolver.resolveCandidateUploadRoots("conv")).thenReturn(List.of(uploadRoot)); + WorkspaceBoundaryGuardian g = new WorkspaceBoundaryGuardian(resolver); + + assertBlocked(g.evaluate(read(outside.toString(), WORKSPACE))); + } + + @Test + @DisplayName("A path inside a different conversation's upload dir stays blocked") + void chatUpload_otherConversation_blocked(@TempDir Path uploadRoot) throws Exception { + Path otherConvFile = Files.createDirectories(uploadRoot.resolve("other-conv")) + .resolve("1777391026594_secret.txt"); + Files.writeString(otherConvFile, "someone else's attachment"); + + ChatUploadLocationResolver resolver = mock(ChatUploadLocationResolver.class); + when(resolver.resolveCandidateUploadRoots("conv")).thenReturn(List.of(uploadRoot)); + WorkspaceBoundaryGuardian g = new WorkspaceBoundaryGuardian(resolver); + + assertBlocked(g.evaluate(read(otherConvFile.toString(), WORKSPACE))); + } + + @Test + @DisplayName("Resolver failure keeps the BLOCK finding (fail closed)") + void chatUpload_resolverFailure_blocked() { + ChatUploadLocationResolver resolver = mock(ChatUploadLocationResolver.class); + when(resolver.resolveCandidateUploadRoots("conv")) + .thenThrow(new RuntimeException("db down")); + WorkspaceBoundaryGuardian g = new WorkspaceBoundaryGuardian(resolver); + + assertBlocked(g.evaluate(read("/somewhere/else/file.txt", WORKSPACE))); + } + // ==================== Tool-result spill dir stays reachable (issue #403) ==================== @Test