mateclaw/mateclaw-server/src/test/java/vip/mate/agent/AgentGraphBuilderBasePathResolutionTest.java
matevip a37074a9a6 fix(tool): close three sandbox follow-up gaps surfaced by review
1. Relative parent traversal in shell commands (HIGH)

   validateShellCommand only scanned absolute path tokens, so commands
   like `cat ../mateclaw/CLAUDE.md`, `cd .. && cat foo`, or
   `ln -sf ../bar breakout` had no absolute path to trip the check.
   From a workspace cwd that's a real escape — `..` segments resolve
   against the JVM cwd at file-tool time and reach anywhere the user
   can read.

   Add a second pass: any token containing `..` as a path segment is
   resolved against the workspace root via root.resolve(token).
   normalize(); reject when the result falls outside. In-workspace
   traversal like `subdir/../sibling` normalizes back inside and
   passes. Identifiers without slashes (e.g. version strings with
   `1.2..3`) are not treated as paths.

2. Shell validation and process working directory used different
   context sources (MEDIUM)

   execute_shell_command validated with the explicit ToolContext, but
   buildShellProcess called WorkspacePathGuard.getWorkingDirectory()
   (no-arg), which only sees the ThreadLocal fallback. Today the
   ToolExecutionExecutor sets both so the discrepancy is latent, but
   a future direct Spring AI invocation passing only ToolContext would
   validate against one basePath and exec against another. Thread ctx
   through buildShellProcess and call getWorkingDirectory(ctx) so
   validation and execution agree on a single source of truth.

3. Absolute agent override could disable workspace scoping (MEDIUM)

   resolveAgentBasePath accepted an absolute override verbatim, even
   when it pointed outside the workspace root. An admin (or any
   account with agent-edit permission) could set workspaceBasePath="/"
   or another team's repo and bypass workspace boundaries entirely.

   When a workspace has its own basePath, require absolute overrides
   to sit underneath it. The caller in build() catches the rejection,
   logs WARN, and falls back to the workspace basePath so chat stays
   available rather than crashing agent construction. When the
   workspace has no basePath there's no boundary to enforce, so legacy
   behavior is preserved.

Test coverage: WorkspacePathGuardShellTest grows from 17 to 23 (six
new cases for `cd ..`, relative parent traversal, relative symlink
escape, deeper traversal, in-workspace normalization, and the
identifier false-positive guard). AgentGraphBuilderBasePathResolutionTest
grows from 7 to 10 (three new cases for in-workspace absolute,
outside-workspace absolute rejection, and no-workspace legacy
behavior). All 45 sandbox-area tests pass with no regressions.
2026-05-25 17:55:56 +08:00

123 lines
5.5 KiB
Java

package vip.mate.agent;
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.EnabledOnOs;
import org.junit.jupiter.api.condition.OS;
import java.nio.file.Paths;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Verifies the agent-vs-workspace basePath precedence rules used by
* {@link AgentGraphBuilder#resolveAgentBasePath(String, String)}.
*
* <p>The UI advertises agent-level paths as "relative to the workspace root",
* so a relative agent override must compose with the workspace basePath rather
* than fall through to the JVM working directory.
*/
class AgentGraphBuilderBasePathResolutionTest {
@Test
@DisplayName("Both null/blank → null (no working directory configured)")
void noOverrideNoWorkspace_returnsNull() {
assertNull(AgentGraphBuilder.resolveAgentBasePath(null, null));
assertNull(AgentGraphBuilder.resolveAgentBasePath("", ""));
assertNull(AgentGraphBuilder.resolveAgentBasePath(" ", null));
}
@Test
@DisplayName("No agent override → workspace basePath inherited verbatim")
void noOverride_inheritsWorkspace() {
assertEquals("/srv/ws-root",
AgentGraphBuilder.resolveAgentBasePath(null, "/srv/ws-root"));
assertEquals("/srv/ws-root",
AgentGraphBuilder.resolveAgentBasePath("", "/srv/ws-root"));
}
@Test
@DisplayName("Agent override is absolute and inside workspace → used verbatim")
@DisabledOnOs(OS.WINDOWS)
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) and inside workspace → used verbatim")
@EnabledOnOs(OS.WINDOWS)
void absoluteOverride_insideWs_usedAsIs_windows() {
assertEquals("C:\\ws-root\\agents\\code-review",
AgentGraphBuilder.resolveAgentBasePath("C:\\ws-root\\agents\\code-review", "C:\\ws-root"));
}
@Test
@DisplayName("Relative agent override + workspace basePath → resolved under workspace")
void relativeOverride_resolvedUnderWorkspace() {
String expected = Paths.get("/srv/ws-root").resolve("projects/code-review").toString();
assertEquals(expected,
AgentGraphBuilder.resolveAgentBasePath("projects/code-review", "/srv/ws-root"));
}
@Test
@DisplayName("Relative agent override with no workspace → used verbatim (legacy fallback)")
void relativeOverride_noWorkspace_usedAsIs() {
assertEquals("projects/code-review",
AgentGraphBuilder.resolveAgentBasePath("projects/code-review", null));
assertEquals("projects/code-review",
AgentGraphBuilder.resolveAgentBasePath("projects/code-review", ""));
}
@Test
@DisplayName("Blank workspace basePath treated like null when override is relative")
void relativeOverride_blankWorkspace_usedAsIs() {
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", ""));
}
}