diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 88ec7228..5deb483e 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -375,11 +375,23 @@ public class AgentGraphBuilder { entity.getName(), e.getMessage()); } } - String resolvedBase = resolveAgentBasePath(entity.getWorkspaceBasePath(), workspaceBase); + String resolvedBase; + try { + resolvedBase = resolveAgentBasePath(entity.getWorkspaceBasePath(), workspaceBase); + } catch (IllegalArgumentException e) { + // Override violates the workspace-scoping rule (e.g. admin tried to + // set an absolute path outside the workspace root). Fall back to + // inheriting the workspace basePath so chat stays available, but + // surface the violation in logs so the admin can fix it. + log.warn("Agent {} workspaceBasePath override rejected, falling back to workspace: {}", + entity.getName(), e.getMessage()); + resolvedBase = workspaceBase; + } if (resolvedBase != null && !resolvedBase.isBlank()) { agent.workspaceBasePath = resolvedBase; boolean fromOverride = entity.getWorkspaceBasePath() != null - && !entity.getWorkspaceBasePath().isBlank(); + && !entity.getWorkspaceBasePath().isBlank() + && resolvedBase.equals(entity.getWorkspaceBasePath()); log.info("Agent {} basePath = {} (source: {})", entity.getName(), resolvedBase, fromOverride ? "agent-override" : "workspace"); } @@ -1165,7 +1177,12 @@ public class AgentGraphBuilder { *

Precedence: *

    *
  1. When the agent-level override is set, it wins.
  2. - *
  3. An absolute override is used verbatim.
  4. + *
  5. An absolute override is used verbatim, but only when it sits + * inside the workspace basePath (or when the workspace has no + * basePath of its own). An absolute path that points outside a + * configured workspace root is rejected — otherwise a less-trusted + * user with agent-edit access could set + * {@code workspaceBasePath="/"} and bypass workspace scoping.
  6. *
  7. A relative override is resolved under the workspace basePath * when the workspace has one, matching the UI hint that agent paths * are relative to the workspace root.
  8. @@ -1174,6 +1191,9 @@ public class AgentGraphBuilder { *
  9. With no override, the workspace basePath is inherited verbatim; * returns {@code null} when neither side has a value.
  10. *
+ * + * @throws IllegalArgumentException when an absolute override escapes the + * workspace root */ static String resolveAgentBasePath(String agentOverride, String workspaceBase) { boolean hasOverride = agentOverride != null && !agentOverride.isBlank(); @@ -1183,6 +1203,15 @@ public class AgentGraphBuilder { } Path overridePath = Paths.get(agentOverride); if (overridePath.isAbsolute()) { + if (hasWorkspace) { + Path wsRoot = Paths.get(workspaceBase).toAbsolutePath().normalize(); + Path absOverride = overridePath.toAbsolutePath().normalize(); + if (!absOverride.startsWith(wsRoot)) { + throw new IllegalArgumentException( + "Agent workspaceBasePath override must be inside the workspace root: " + + absOverride + " is not under " + wsRoot); + } + } return agentOverride; } if (hasWorkspace) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java index f0c9067e..aff0298a 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ShellExecuteTool.java @@ -91,7 +91,7 @@ public class ShellExecuteTool { // Windows cmd.exe 会在第一个换行处截断命令,Unix sh 也可能误解 String sanitizedCommand = collapseEmbeddedNewlines(command); - ProcessBuilder pb = buildShellProcess(sanitizedCommand); + ProcessBuilder pb = buildShellProcess(sanitizedCommand, ctx); // 不继承环境变量中的敏感信息 pb.environment().keySet().removeIf(key -> key.contains("KEY") || key.contains("SECRET") || key.contains("TOKEN") @@ -157,7 +157,7 @@ public class ShellExecuteTool { * from the calling environment still apply; falls back to /bin/sh * when $SHELL is unset or points at a non-executable path. */ - private static ProcessBuilder buildShellProcess(String command) { + private static ProcessBuilder buildShellProcess(String command, @Nullable ToolContext ctx) { ProcessBuilder pb; if (IS_WINDOWS) { String winCommand = sanitizeWindowsCommand(command); @@ -167,8 +167,13 @@ public class ShellExecuteTool { pb = new ProcessBuilder(shell, "-c", command); } - // 设置工作区活动目录 - java.nio.file.Path workingDir = vip.mate.tool.guard.WorkspacePathGuard.getWorkingDirectory(); + // Pin the process cwd to the same workspace basePath the validator + // checked against. Using getWorkingDirectory(ctx) (not the no-arg + // ThreadLocal-only overload) keeps validation and execution on a + // single source of truth — otherwise a caller that only sets + // ToolContext could validate against one basePath and run with the + // ThreadLocal fallback's basePath. + java.nio.file.Path workingDir = vip.mate.tool.guard.WorkspacePathGuard.getWorkingDirectory(ctx); if (workingDir != null && java.nio.file.Files.isDirectory(workingDir)) { pb.directory(workingDir.toFile()); log.info("[ShellExecute] Working directory set to: {}", workingDir); 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 03984553..4ad95a1a 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 @@ -114,6 +114,11 @@ public final class WorkspacePathGuard { * {@code cd /var}) whose normalized form is not under the workspace * root — even when nested inside command substitution {@code $(...)} * or backticks; + *
  • relative tokens containing {@code ..} as a directory segment + * (e.g. {@code cd ..}, {@code cat ../foo}, {@code ln -s ../bar baz}) + * when the resolved path falls outside the workspace root — + * in-workspace traversal like {@code subdir/../sibling} is allowed + * because it normalizes back inside;
  • *
  • tilde expansion ({@code ~}, {@code ~/...}) — always resolves to * {@code $HOME}, which sits outside the workspace;
  • *
  • references to environment variables ({@code $HOME}, {@code ${USER}}, @@ -193,6 +198,28 @@ public final class WorkspacePathGuard { + normalized + ", allowed root: " + root); } } + + // 4. Relative tokens containing ".." — must resolve inside the workspace. + // Catches `cd ..`, `cat ../foo`, `ln -s ../bar baz`, `mv foo/../bar dst`, + // etc. In-workspace traversal (`subdir/../sibling`) normalizes back + // inside and passes. + Matcher traversalMatch = RELATIVE_TRAVERSAL_TOKEN.matcher(command); + while (traversalMatch.find()) { + String candidate = traversalMatch.group(1); + Path resolved; + try { + resolved = root.resolve(candidate).normalize(); + } catch (Exception ex) { + continue; + } + if (isAllowedDeviceNode(resolved)) continue; + if (!resolved.startsWith(root)) { + throw new IllegalArgumentException( + "Shell command uses parent-directory traversal that escapes the workspace: '" + + candidate + "' would resolve to " + resolved + + ", allowed root: " + root); + } + } } /** @@ -206,6 +233,28 @@ public final class WorkspacePathGuard { private static final Pattern ABS_PATH_TOKEN = Pattern.compile( "(?:^|[\\s|&;<>(`\"'={}])(?()\"'`{}=]+)"); + /** + * Match relative tokens that contain {@code ..} as a path segment. Captures + * the whole token (prefix + {@code ..} + optional suffix) so the caller + * can resolve it against the workspace root and decide whether it escapes. + * + *

    Matches: + *

    + * + *

    Does NOT match {@code abc..xyz} (no slash before/after the {@code ..} — + * not a path segment) or absolute {@code /foo/../bar} (handled by + * {@link #ABS_PATH_TOKEN}). The token must be bounded by a shell separator + * or end-of-string on both sides. + */ + private static final Pattern RELATIVE_TRAVERSAL_TOKEN = Pattern.compile( + "(?:^|[\\s|&;<>(`\"'={}])((?:[^\\s|&;<>()\"'`{}=/]+/)*\\.\\.(?:/[^\\s|&;<>()\"'`{}=]*)?)(?=[\\s|&;<>)`\"'=}]|$)"); + /** Bare tilde or tilde at the start of a path token: {@code ~}, {@code ~/foo}, {@code "~/bar"}. */ private static final Pattern TILDE_REF = Pattern.compile( "(?:^|[\\s|&;<>(`\"'={}])~(?=[/\\s|&;<>)`\"'$]|$)"); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderBasePathResolutionTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderBasePathResolutionTest.java index 01321c23..5af64342 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderBasePathResolutionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderBasePathResolutionTest.java @@ -39,21 +39,21 @@ class AgentGraphBuilderBasePathResolutionTest { } @Test - @DisplayName("Agent override is absolute → used verbatim, workspace ignored") + @DisplayName("Agent override is absolute and inside workspace → used verbatim") @DisabledOnOs(OS.WINDOWS) - void absoluteOverride_usedAsIs_unix() { - assertEquals("/opt/agents/code-review", - AgentGraphBuilder.resolveAgentBasePath("/opt/agents/code-review", "/srv/ws-root")); + void absoluteOverride_insideWs_usedAsIs_unix() { + assertEquals("/srv/ws-root/agents/code-review", + AgentGraphBuilder.resolveAgentBasePath("/srv/ws-root/agents/code-review", "/srv/ws-root")); assertEquals("/opt/agents/code-review", AgentGraphBuilder.resolveAgentBasePath("/opt/agents/code-review", null)); } @Test - @DisplayName("Agent override is absolute (Windows) → used verbatim") + @DisplayName("Agent override is absolute (Windows) and inside workspace → used verbatim") @EnabledOnOs(OS.WINDOWS) - void absoluteOverride_usedAsIs_windows() { - assertEquals("C:\\agents\\code-review", - AgentGraphBuilder.resolveAgentBasePath("C:\\agents\\code-review", "C:\\ws-root")); + void absoluteOverride_insideWs_usedAsIs_windows() { + assertEquals("C:\\ws-root\\agents\\code-review", + AgentGraphBuilder.resolveAgentBasePath("C:\\ws-root\\agents\\code-review", "C:\\ws-root")); } @Test @@ -79,4 +79,44 @@ class AgentGraphBuilderBasePathResolutionTest { assertEquals("agent-dir", AgentGraphBuilder.resolveAgentBasePath("agent-dir", " ")); } + + // ==================== Absolute override scoped to workspace root ==================== + + @Test + @DisplayName("Absolute override inside workspace root → allowed verbatim") + @DisabledOnOs(OS.WINDOWS) + void absoluteOverride_insideWorkspace_allowed() { + // /srv/ws-root/agents/code-review starts with /srv/ws-root → fine. + assertEquals("/srv/ws-root/agents/code-review", + AgentGraphBuilder.resolveAgentBasePath("/srv/ws-root/agents/code-review", "/srv/ws-root")); + // Identical to workspace root → trivially allowed. + assertEquals("/srv/ws-root", + AgentGraphBuilder.resolveAgentBasePath("/srv/ws-root", "/srv/ws-root")); + } + + @Test + @DisplayName("Absolute override outside workspace root → rejected (workspace-scoping bypass)") + @DisabledOnOs(OS.WINDOWS) + void absoluteOverride_outsideWorkspace_rejected() { + // A less-trusted admin could set / or another repo and bypass scoping — + // reject it so the override stays inside the team workspace. + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, () -> + AgentGraphBuilder.resolveAgentBasePath("/etc", "/srv/ws-root")); + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, () -> + AgentGraphBuilder.resolveAgentBasePath("/", "/srv/ws-root")); + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, () -> + AgentGraphBuilder.resolveAgentBasePath("/Users/admin/other-project", + "/srv/ws-root")); + } + + @Test + @DisplayName("Absolute override with no workspace basePath → used verbatim (legacy)") + @DisabledOnOs(OS.WINDOWS) + void absoluteOverride_noWorkspace_allowed() { + // No workspace boundary to enforce — fall back to legacy behavior. + assertEquals("/Users/admin/scratch", + AgentGraphBuilder.resolveAgentBasePath("/Users/admin/scratch", null)); + assertEquals("/Users/admin/scratch", + AgentGraphBuilder.resolveAgentBasePath("/Users/admin/scratch", "")); + } } 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 1f8139e4..7b694b2e 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 @@ -205,6 +205,65 @@ class WorkspacePathGuardShellTest { WorkspacePathGuard.validateShellCommand("read line < /dev/fd/3")); } + // ==================== Relative parent-directory traversal ==================== + + @Test + @DisplayName("Bare `cd ..` → rejected") + void cdDotDot_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cd .. && ls")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cd ..")); + } + + @Test + @DisplayName("Relative parent traversal `../foo` → rejected") + void relativeParentTraversal_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat ../mateclaw/CLAUDE.md")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("head -3 ../README.md")); + } + + @Test + @DisplayName("Symlink creation with relative outside-pointing target → rejected") + void relativeSymlinkEscape_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("ln -sf ../mateclaw breakout")); + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("ln -s ../../etc shortcut")); + } + + @Test + @DisplayName("Deeper relative traversal `foo/../../bar` → rejected") + void deepRelativeTraversal_blocked() { + assertThrows(IllegalArgumentException.class, () -> + WorkspacePathGuard.validateShellCommand("cat subdir/../../other/file.txt")); + } + + @Test + @DisplayName("In-workspace `..` traversal that normalizes back inside → allowed") + void inWorkspaceTraversal_pass() { + // subdir/../sibling → workspace/sibling, still inside. + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("cat subdir/../sibling.txt")); + // ./.. is at workspace root after normalize — still inside? No: ./.. is parent of cwd. + // We do want to reject that, which the regex will catch as escape. + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("ls foo/..")); // resolves to workspace root + } + + @Test + @DisplayName("Identifier with double-dot but no slash (e.g. `abc..xyz`) is not a path → allowed") + void doubleDotInIdentifier_pass() { + // "..foo" / "abc..xyz" should not be confused with parent traversal. + // These appear in version strings, env var values, etc. + assertDoesNotThrow(() -> + WorkspacePathGuard.validateShellCommand("echo version=1.2..3")); + } + + // ==================== Device-node negative cases ==================== + @Test @DisplayName("Non-allowlisted /dev/* paths still rejected") void devOther_blocked() {