fix(tool): allow standard /dev/* character devices in shell sandbox

This commit is contained in:
matevip 2026-05-25 16:38:31 +08:00
parent b09a220ec7
commit 7272d8f633
2 changed files with 79 additions and 0 deletions

View File

@ -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<String> 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;
}

View File

@ -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"));
}
}