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 5569aa8e..03984553 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,7 @@ import vip.mate.tool.builtin.ToolExecutionContext; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -180,6 +181,12 @@ public final class WorkspacePathGuard { // Unparseable as a path — leave it alone, not our concern. continue; } + if (isAllowedDeviceNode(normalized)) { + // Character devices like /dev/null, /dev/stdin, /dev/fd/0 don't + // expose any on-disk user data — allow them so common shell + // idioms (`2>/dev/null`, `cmd <(cat file)`) keep working. + continue; + } if (!normalized.startsWith(root)) { throw new IllegalArgumentException( "Shell command references path outside workspace boundary: " @@ -211,6 +218,32 @@ public final class WorkspacePathGuard { private static final Pattern OUTSIDE_ENV_VAR = Pattern.compile( "\\$\\{?(HOME|USER|LOGNAME|TMPDIR|TMP|TEMP|PWD|OLDPWD|PATH|MAIL)\\b"); + /** + * Character device nodes that don't expose user data and are needed for + * common shell idioms (stderr suppression, process substitution, entropy). + * Linux/macOS only — the path strings are absolute POSIX paths; on + * Windows {@link #validateShellCommand} doesn't fire on these because + * a Windows command wouldn't normalize to a {@code /dev/...} string. + */ + private static final Set ALLOWED_DEVICE_NODES = Set.of( + "/dev/null", + "/dev/zero", + "/dev/stdin", + "/dev/stdout", + "/dev/stderr", + "/dev/random", + "/dev/urandom", + "/dev/tty" + ); + + /** Match {@code /dev/fd/0}, {@code /dev/fd/1}, etc — used by process substitution. */ + private static final Pattern ALLOWED_DEV_FD = Pattern.compile("^/dev/fd/\\d+$"); + + private static boolean isAllowedDeviceNode(Path normalized) { + String s = normalized.toString(); + return ALLOWED_DEVICE_NODES.contains(s) || ALLOWED_DEV_FD.matcher(s).matches(); + } + private static String truncateForError(String s) { return s.length() > 200 ? s.substring(0, 200) + "..." : s; } 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 70c42fbd..1f8139e4 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 @@ -172,4 +172,50 @@ class WorkspacePathGuardShellTest { assertDoesNotThrow(() -> WorkspacePathGuard.validateShellCommand("printf '%s\\n' \"$MY_FLAG\"")); } + + // ==================== Device-node allowlist ==================== + + @Test + @DisplayName("/dev/null and other standard device nodes are allowed") + void deviceNodes_pass() { + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("find . -name '*.md' 2>/dev/null")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("ls -la > /dev/null")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("cat /dev/urandom | head -c 16")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("dd if=/dev/zero of=zeros.bin bs=1024 count=1")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("read line < /dev/stdin")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("echo hi > /dev/stderr")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("tty < /dev/tty")); + } + + @Test + @DisplayName("/dev/fd/N (process substitution) is allowed") + void devFd_pass() { + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("diff <(sort file_a.txt) <(sort file_b.txt)")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("cat /dev/fd/0")); + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("read line < /dev/fd/3")); + } + + @Test + @DisplayName("Non-allowlisted /dev/* paths still rejected") + void devOther_blocked() { + // Block-device-like paths must not be allowed by the allowlist. + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("dd if=/dev/disk0 of=image.bin")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat /dev/loop0")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("ls /dev/null/sneak")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat /dev/fd/notanumber")); + } }