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.
This commit is contained in:
matevip 2026-05-25 17:55:56 +08:00
parent 7272d8f633
commit a37074a9a6
5 changed files with 197 additions and 15 deletions

View File

@ -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 {
* <p>Precedence:
* <ol>
* <li>When the agent-level override is set, it wins.</li>
* <li>An absolute override is used verbatim.</li>
* <li>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.</li>
* <li>A relative override is resolved <em>under</em> the workspace basePath
* when the workspace has one, matching the UI hint that agent paths
* are relative to the workspace root.</li>
@ -1174,6 +1191,9 @@ public class AgentGraphBuilder {
* <li>With no override, the workspace basePath is inherited verbatim;
* returns {@code null} when neither side has a value.</li>
* </ol>
*
* @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) {

View File

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

View File

@ -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;</li>
* <li>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;</li>
* <li>tilde expansion ({@code ~}, {@code ~/...}) always resolves to
* {@code $HOME}, which sits outside the workspace;</li>
* <li>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|&;<>(`\"'={}])(?<!:)(/[^\\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.
*
* <p>Matches:
* <ul>
* <li>{@code ..} ({@code cd ..}, bare arg)</li>
* <li>{@code ../foo/bar} (relative parent traversal)</li>
* <li>{@code ./..} ({@code cd ./..})</li>
* <li>{@code foo/..} ({@code rm foo/..})</li>
* <li>{@code foo/../bar} (in-workspace normalization)</li>
* </ul>
*
* <p>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|&;<>)`\"'$]|$)");

View File

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

View File

@ -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() {