Skip to main content
xbot
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage
Edit page

修复沙箱内置工具(Glob / Grep / Read / Edit / Skill)文件查找问题

版本:v2(门下省审核修订版) 修订内容:补充 edit.go / skill.go 修复;安全加固升级为必做;补充边界测试

1. 问题概述

在沙箱模式下,内置工具无法正确查找文件,核心原因是路径硬编码和 glob-to-find 翻译错误。

工具现象严重程度
Glob**/*.gosrc/*.go 等含路径的 pattern 永远匹配不到文件致命
Grep--include='*.{go,ts}' 不生效,grep 不支持 brace expansion
Read路径硬编码 /workspace,未使用 ctx.SandboxWorkDir
Edit路径硬编码 /workspace,未使用 ctx.SandboxWorkDir
Skill沙箱 skill 目录硬编码 /workspace/skills/workspace/.skills

2. 根因分析

2.1 Glob:find -name 无法匹配路径中的 /

文件/workspace/xbot/tools/glob.goexecuteInSandbox 方法

findCmd := fmt.Sprintf(
    "find %s -type f -name '%s' -not -path '*/.*' -not -path '*/node_modules/*' 2>/dev/null | head -200",
    searchDir, pattern)

find -name 只匹配文件 basename,完全不支持路径分隔符 /

Glob Pattern当前生成的 find 命令结果
*.gofind /workspace -name '*.go'❌ 递归搜索(用户期望仅当前目录)
**/*.gofind /workspace -name '**/*.go'❌ 永远不匹配(basename 不含 /
src/*.gofind /workspace -name 'src/*.go'❌ 永远不匹配
**/test/*.gofind /workspace -name '**/test/*.go'❌ 永远不匹配

2.2 Grep:brace 展开缺失

文件/workspace/xbot/tools/grep.goexecuteInSandbox 方法

grepCmd += fmt.Sprintf(" --include='%s'", include)

include="*.{go,ts}" 时,生成 --include='*.{go,ts}'。grep 本身不支持 brace expansion。

注意:expandBracePattern 函数已在 grep.go 中实现,但仅在 executeLocal 中使用,executeInSandbox 未调用。

2.3 路径硬编码 /workspace(影响 4 个工具)

ToolContext.SandboxWorkDirtools/interface.go)专门存储沙箱内工作目录,非沙箱时与 WorkspaceRoot 相同。以下文件均硬编码 "/workspace" 而未使用该字段:

文件硬编码位置正确参考
glob.goexecuteInSandboxsearchDir := "/workspace"cd.go 使用 ctx.SandboxWorkDir
grep.goexecuteInSandboxsearchDir := "/workspace"同上
read.goexecuteInSandbox 中多处 "/workspace" 拼接同上
edit.goexecuteInSandbox"/workspace/" + params.Path同上

2.4 Skill 沙箱路径硬编码

文件/workspace/xbot/tools/skill.go,约第 84-85 行

{filepath.Join(ctx.WorkspaceRoot, "skills"), "/workspace/skills"},
{filepath.Join(ctx.WorkspaceRoot, ".skills"), "/workspace/.skills"},

SandboxWorkDir 不是 /workspace 时,skill 在沙箱内找不到。

2.5 Shell 注入风险(安全漏洞)

所有沙箱工具通过 fmt.Sprintf 将用户输入(pattern、searchDir、include)直接拼入 shell 命令,未做任何转义。攻击者可通过精心构造的 pattern 注入任意 shell 命令。

3. 修复方案

3.1 新增公共辅助函数(path_guard.go)

位置/workspace/xbot/tools/path_guard.go

选择此文件是因为它已包含 SandboxToHostPath / HostToSandboxPath 等沙箱路径转换函数,语义内聚。

// sandboxBaseDir 返回沙箱内的工作目录前缀。
// 优先使用 ctx.SandboxWorkDir,兜底为 "/workspace"。
func sandboxBaseDir(ctx *ToolContext) string {
    if ctx != nil && ctx.SandboxWorkDir != "" {
        return ctx.SandboxWorkDir
    }
    return "/workspace"
}

// shellEscape 对字符串进行 shell 单引号转义,防止命令注入。
// 将字符串中的单引号替换为 '\''
func shellEscape(s string) string {
    return strings.ReplaceAll(s, "'", "'\\''")
}

3.2 新增函数:globToFindArgs(glob.go)

位置/workspace/xbot/tools/glob.go,新增函数

// globToFindArgs 将 glob pattern 翻译为 find 命令的参数。
// 返回值:(find 搜索子目录, find 过滤参数片段)
//
// 翻译规则:
//   - *.go            → ("", "-maxdepth 1 -name '*.go'")
//   - **/*.go         → ("", "-name '*.go'")               // 递归
//   - src/*.go        → ("src", "-maxdepth 1 -name '*.go'")
//   - src/**/*.go     → ("src", "-name '*.go'")            // 递归
//   - **/test/*.go    → ("", "-path '*/test/*.go'")        // 递归
//   - src/**/test/*.go→ ("src", "-path '*/test/*.go'")     // 递归
func globToFindArgs(pattern string) (searchBase string, args string)

算法

func globToFindArgs(pattern string) (searchBase string, args string) {
    pattern = strings.Trim(pattern, "/")
    pattern = filepath.ToSlash(pattern)
    if pattern == "" {
        return "", ""
    }

    segments := strings.Split(pattern, "/")

    // 定位第一个 ** 的位置
    doubleStarIdx := -1
    for i, seg := range segments {
        if seg == "**" {
            doubleStarIdx = i
            break
        }
    }

    if doubleStarIdx == -1 {
        // 无 **:简单匹配,-maxdepth 1 限定不递归
        if len(segments) == 1 {
            return "", fmt.Sprintf("-maxdepth 1 -name '%s'", segments[0])
        }
        base := strings.Join(segments[:len(segments)-1], "/")
        name := segments[len(segments)-1]
        return base, fmt.Sprintf("-maxdepth 1 -name '%s'", name)
    }

    // 有 **:
    prefix := strings.Join(segments[:doubleStarIdx], "/")
    suffixSegments := segments[doubleStarIdx+1:]

    if len(suffixSegments) == 0 {
        return prefix, ""
    }

    if len(suffixSegments) == 1 {
        return prefix, fmt.Sprintf("-name '%s'", suffixSegments[0])
    }

    // 多个后缀 segment:用 -path
    pathPattern := "*/" + strings.Join(suffixSegments, "/")
    return prefix, fmt.Sprintf("-path '%s'", pathPattern)
}

3.3 修改 GlobTool.executeInSandbox(glob.go)

修改要点

  1. 使用 sandboxBaseDir(ctx) 替代硬编码
  2. 调用 globToFindArgs 翻译 pattern
  3. 对用户输入做 shellEscape
func (t *GlobTool) executeInSandbox(ctx *ToolContext, pattern, path string) (*ToolResult, error) {
    sandboxBase := sandboxBaseDir(ctx)

    // 翻译 glob pattern → find 参数
    searchBase, findArgs := globToFindArgs(pattern)

    // 确定 find 搜索目录
    searchDir := sandboxBase
    if path != "" {
        if strings.HasPrefix(path, sandboxBase+"/") {
            searchDir = path
        } else {
            searchDir = sandboxBase + "/" + path
        }
    } else if ctx != nil && ctx.CurrentDir != "" {
        if strings.HasPrefix(ctx.CurrentDir, ctx.WorkspaceRoot) {
            rel, _ := filepath.Rel(ctx.WorkspaceRoot, ctx.CurrentDir)
            searchDir = sandboxBase + "/" + rel
        }
    }

    // 合并 globToFindArgs 的子目录前缀
    if searchBase != "" {
        searchDir = searchDir + "/" + searchBase
    }

    // 构建 find 命令(对 searchDir 做 shellEscape 防注入)
    escapedDir := shellEscape(searchDir)
    findCmd := fmt.Sprintf(
        "find %s -type f %s -not -path '*/.*' -not -path '*/node_modules/*' 2>/dev/null | head -200",
        escapedDir, findArgs)

    output, err := RunInSandboxWithShell(ctx, findCmd)
    // ... 其余不变
}

3.4 修改 GrepTool.executeInSandbox(grep.go)

修改要点

  1. 使用 sandboxBaseDir(ctx) 替代硬编码
  2. include 调用 expandBracePattern 展开 brace
  3. 对用户输入 patternsearchDirshellEscape
// include brace 展开(复用已有函数)
if include != "" {
    patterns := expandBracePattern(include)
    for _, p := range patterns {
        grepCmd += fmt.Sprintf(" --include='%s'", shellEscape(p))
    }
}

// pattern 和 searchDir 也需要 shellEscape
grepCmd += fmt.Sprintf(" %s %s", shellEscape(pattern), shellEscape(searchDir))

3.5 修改 ReadTool.executeInSandbox(read.go)

修改要点:将所有 "/workspace" 替换为 sandboxBaseDir(ctx),对 sandboxPathshellEscape

sandboxBase := sandboxBaseDir(ctx)
// ... 路径解析逻辑中使用 sandboxBase 替代 "/workspace" ...
cmd := fmt.Sprintf("cat '%s'", shellEscape(sandboxPath))

3.6 修改 EditTool.executeInSandbox(edit.go)

文件/workspace/xbot/tools/edit.go

修改要点:将 "/workspace" 硬编码替换为 sandboxBaseDir(ctx)

当前代码(约第 135-142 行):

if !strings.HasPrefix(params.Path, "/workspace/") && !strings.HasPrefix(params.Path, "/") {
    sandboxPath = "/workspace/" + params.Path
} else if strings.HasPrefix(params.Path, "/workspace/") {
    sandboxPath = params.Path
} else if strings.HasPrefix(params.Path, "/") {
    rel, _ := filepath.Rel(ctx.WorkspaceRoot, params.Path)
    sandboxPath = "/workspace/" + rel
}

修改为:

sandboxBase := sandboxBaseDir(ctx)
if !strings.HasPrefix(params.Path, sandboxBase+"/") && !strings.HasPrefix(params.Path, "/") {
    sandboxPath = sandboxBase + "/" + params.Path
} else if strings.HasPrefix(params.Path, sandboxBase+"/") {
    sandboxPath = params.Path
} else if strings.HasPrefix(params.Path, "/") {
    rel, _ := filepath.Rel(ctx.WorkspaceRoot, params.Path)
    sandboxPath = sandboxBase + "/" + rel
}

3.7 修改 SkillTool 沙箱路径映射(skill.go)

文件/workspace/xbot/tools/skill.go,约第 84-85 行

当前代码:

candidates = []candidate{
    {filepath.Join(ctx.WorkspaceRoot, "skills"), "/workspace/skills"},
    {filepath.Join(ctx.WorkspaceRoot, ".skills"), "/workspace/.skills"},
}

修改为:

sandboxBase := sandboxBaseDir(ctx)
candidates = []candidate{
    {filepath.Join(ctx.WorkspaceRoot, "skills"), filepath.Join(sandboxBase, "skills")},
    {filepath.Join(ctx.WorkspaceRoot, ".skills"), filepath.Join(sandboxBase, ".skills")},
}

4. 修改文件清单

文件修改类型具体变更
tools/path_guard.go新增函数sandboxBaseDir(ctx) + shellEscape(s)
tools/glob.go新增函数globToFindArgs(pattern) (string, string)
tools/glob.go修改方法executeInSandboxglobToFindArgs + sandboxBaseDir + shellEscape
tools/grep.go修改方法executeInSandbox:brace 展开 + sandboxBaseDir + shellEscape
tools/read.go修改方法executeInSandboxsandboxBaseDir + shellEscape
tools/edit.go修改方法executeInSandboxsandboxBaseDir 替换硬编码
tools/skill.go修改方法沙箱路径映射使用 sandboxBaseDir
tools/path_guard_test.go新增测试TestSandboxBaseDir + TestShellEscape
tools/glob_test.go新增测试TestGlobToFindArgs(含边界 case)

5. 测试策略

5.1 globToFindArgs 单元测试

PatternsearchBaseargs
*.go""-maxdepth 1 -name '*.go'
*.txt""-maxdepth 1 -name '*.txt'
*""-maxdepth 1 -name '*'
src/*.go"src"-maxdepth 1 -name '*.go'
pkg/utils/*.go"pkg/utils"-maxdepth 1 -name '*.go'
a/b/c/*.go"a/b/c"-maxdepth 1 -name '*.go'
**/*.go""-name '*.go'
**/*.ts""-name '*.ts'
src/**/*.go"src"-name '*.go'
**/test/*.go""-path '*/test/*.go'
src/**/test/*.go"src"-path '*/test/*.go'
**""""
src/**"src"""
**/*""-name '*'
/**/*.go""-name '*.go'
**/*.go/""-name '*.go'
""""""

5.2 shellEscape 单元测试

输入期望输出
hellohello
hello worldhello world
it'sit'\\''s
""
\\
$HOME$HOME
""""

5.3 sandboxBaseDir 单元测试

ctx期望输出
nil/workspace
SandboxWorkDir: "/data/ws"/data/ws
SandboxWorkDir: ""/workspace

5.4 现有测试

运行 go test ./tools/... 确保现有测试不受影响。

6. 影响范围评估

维度评估
影响文件数6 个(path_guard.go、glob.go、grep.go、read.go、edit.go、skill.go)
影响工具数5 个(Glob、Grep、Read、Edit、Skill)
新增公共 API2 个内部函数(globToFindArgssandboxBaseDir),不导出
对非沙箱模式的影响。所有修改仅在沙箱相关代码路径内
对 SubAgent 的影响。SubAgent 通过 RunInSandbox 接口执行
向后兼容性完全兼容。仅修复错误行为,不改变接口签名
回滚方案Git revert 即可。所有修改限定在沙箱路径内

7. 实施顺序

步骤 1: path_guard.go    → 新增 sandboxBaseDir()、shellEscape()          [无依赖]
步骤 2: glob.go           → 新增 globToFindArgs() + 重构 executeInSandbox  [依赖步骤 1]
步骤 3: grep.go           → 重构 executeInSandbox()(brace + escape)     [依赖步骤 1]
步骤 4: read.go           → 重构 executeInSandbox()(sandboxBaseDir)     [依赖步骤 1]
步骤 5: edit.go           → 重构 executeInSandbox()(sandboxBaseDir)     [依赖步骤 1]
步骤 6: skill.go          → 修复沙箱路径映射                               [依赖步骤 1]
步骤 7: 测试文件          → 新增单元测试                                   [依赖步骤 1-2]
步骤 8: 运行测试          → go test ./tools/... 验证通过                  [最后执行]

步骤 2-6 可并行实施。