From bdef3e411f9e266d212db8591684838892424a7b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9D=8E=E9=BE=99=200668001470?=
Date: Mon, 16 Mar 2026 15:27:57 +0800
Subject: [PATCH] fix(exec): keep file URI sandboxing after URL guard refactor
---
pkg/tools/shell.go | 60 +++++++++++++++++++++++++++++++++++++++++
pkg/tools/shell_test.go | 5 +++-
2 files changed, 64 insertions(+), 1 deletion(-)
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index 994535462..2cbca23ae 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
+ "net/url"
"os"
"os/exec"
"path/filepath"
@@ -84,6 +85,9 @@ var (
// A separate boundary check is applied before treating a match as a filesystem path.
absolutePathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
+ // fileURIPathPattern matches file:// URIs that may point at local or UNC paths.
+ fileURIPathPattern = regexp.MustCompile(`file://[^\s\"']+`)
+
// safePaths are kernel pseudo-devices that are always safe to reference in
// commands, regardless of workspace restriction. They contain no user data
// and cannot cause destructive writes.
@@ -376,6 +380,23 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
return ""
}
+ fileURIMatches := fileURIPathPattern.FindAllString(cmd, -1)
+ for _, rawURI := range fileURIMatches {
+ p, ok := fileURIToPath(rawURI, cwdPath)
+ if !ok || safePaths[p] {
+ continue
+ }
+
+ rel, err := filepath.Rel(cwdPath, p)
+ if err != nil {
+ continue
+ }
+
+ if strings.HasPrefix(rel, "..") {
+ return "Command blocked by safety guard (path outside working dir)"
+ }
+ }
+
// Web URL schemes whose path components (starting with //) should be exempt
// from workspace sandbox checks. file: is intentionally excluded so that
// file:// URIs are still validated against the workspace boundary.
@@ -447,6 +468,45 @@ func isPathBoundary(command string, start int) bool {
return strings.ContainsRune(`"'=<>|&;()[]{},`, r)
}
+func fileURIToPath(rawURI, cwdPath string) (string, bool) {
+ u, err := url.Parse(rawURI)
+ if err != nil || u.Scheme != "file" {
+ return "", false
+ }
+
+ path := u.Path
+ if path == "" {
+ path = u.Opaque
+ }
+
+ if path == "" {
+ return "", false
+ }
+
+ if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' {
+ path = path[1:]
+ }
+
+ if u.Host != "" && u.Host != "localhost" {
+ path = "//" + u.Host + path
+ }
+
+ path = filepath.FromSlash(path)
+
+ if runtime.GOOS == "windows" &&
+ filepath.VolumeName(path) == "" &&
+ (strings.HasPrefix(path, `\`) || strings.HasPrefix(path, `/`)) {
+ path = filepath.VolumeName(cwdPath) + path
+ }
+
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return "", false
+ }
+
+ return absPath, true
+}
+
func (t *ExecTool) SetTimeout(timeout time.Duration) {
t.timeout = timeout
}
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index 3463a2c4f..b3fc78b0b 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -528,7 +528,10 @@ func TestShellTool_GuardCommand_BlocksAbsolutePathOutsideWorkspace(t *testing.T)
t.Fatalf("unable to configure exec tool: %s", err)
}
- if got := tool.guardCommand(`cat "`+outsideFile+`"`, workspace); !strings.Contains(got, "path outside working dir") {
+ if got := tool.guardCommand(
+ `cat "`+outsideFile+`"`,
+ workspace,
+ ); !strings.Contains(got, "path outside working dir") {
t.Fatalf("guardCommand should block outside path, got %q", got)
}
}