版本:v2(门下省审核修订版) 修订内容:补充 edit.go / skill.go 修复;安全加固升级为必做;补充边界测试
在沙箱模式下,内置工具无法正确查找文件,核心原因是路径硬编码和 glob-to-find 翻译错误。
| 工具 | 现象 | 严重程度 |
|---|---|---|
| Glob | **/*.go、src/*.go 等含路径的 pattern 永远匹配不到文件 | 致命 |
| Grep | --include='*.{go,ts}' 不生效,grep 不支持 brace expansion | 高 |
| Read | 路径硬编码 /workspace,未使用 ctx.SandboxWorkDir | 高 |
| Edit | 路径硬编码 /workspace,未使用 ctx.SandboxWorkDir | 高 |
| Skill | 沙箱 skill 目录硬编码 /workspace/skills、/workspace/.skills | 高 |
文件:/workspace/xbot/tools/glob.go,executeInSandbox 方法
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 命令 | 结果 |
|---|---|---|
*.go | find /workspace -name '*.go' | ❌ 递归搜索(用户期望仅当前目录) |
**/*.go | find /workspace -name '**/*.go' | ❌ 永远不匹配(basename 不含 /) |
src/*.go | find /workspace -name 'src/*.go' | ❌ 永远不匹配 |
**/test/*.go | find /workspace -name '**/test/*.go' | ❌ 永远不匹配 |
文件:/workspace/xbot/tools/grep.go,executeInSandbox 方法
grepCmd += fmt.Sprintf(" --include='%s'", include)
当 include="*.{go,ts}" 时,生成 --include='*.{go,ts}'。grep 本身不支持 brace expansion。
注意:expandBracePattern 函数已在 grep.go 中实现,但仅在 executeLocal 中使用,executeInSandbox 未调用。
ToolContext.SandboxWorkDir(tools/interface.go)专门存储沙箱内工作目录,非沙箱时与 WorkspaceRoot 相同。以下文件均硬编码 "/workspace" 而未使用该字段:
| 文件 | 硬编码位置 | 正确参考 |
|---|---|---|
glob.go | executeInSandbox 中 searchDir := "/workspace" | cd.go 使用 ctx.SandboxWorkDir |
grep.go | executeInSandbox 中 searchDir := "/workspace" | 同上 |
read.go | executeInSandbox 中多处 "/workspace" 拼接 | 同上 |
edit.go | executeInSandbox 中 "/workspace/" + params.Path 等 | 同上 |
文件:/workspace/xbot/tools/skill.go,约第 84-85 行
{filepath.Join(ctx.WorkspaceRoot, "skills"), "/workspace/skills"},
{filepath.Join(ctx.WorkspaceRoot, ".skills"), "/workspace/.skills"},
当 SandboxWorkDir 不是 /workspace 时,skill 在沙箱内找不到。
所有沙箱工具通过 fmt.Sprintf 将用户输入(pattern、searchDir、include)直接拼入 shell 命令,未做任何转义。攻击者可通过精心构造的 pattern 注入任意 shell 命令。
位置:/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, "'", "'\\''")
}
位置:/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)
}
修改要点:
- 使用
sandboxBaseDir(ctx)替代硬编码 - 调用
globToFindArgs翻译 pattern - 对用户输入做
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)
// ... 其余不变
}
修改要点:
- 使用
sandboxBaseDir(ctx)替代硬编码 - 对
include调用expandBracePattern展开 brace - 对用户输入
pattern、searchDir做shellEscape
// 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))
修改要点:将所有 "/workspace" 替换为 sandboxBaseDir(ctx),对 sandboxPath 做 shellEscape。
sandboxBase := sandboxBaseDir(ctx)
// ... 路径解析逻辑中使用 sandboxBase 替代 "/workspace" ...
cmd := fmt.Sprintf("cat '%s'", shellEscape(sandboxPath))
文件:/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
}
文件:/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")},
}
| 文件 | 修改类型 | 具体变更 |
|---|---|---|
tools/path_guard.go | 新增函数 | sandboxBaseDir(ctx) + shellEscape(s) |
tools/glob.go | 新增函数 | globToFindArgs(pattern) (string, string) |
tools/glob.go | 修改方法 | executeInSandbox:globToFindArgs + sandboxBaseDir + shellEscape |
tools/grep.go | 修改方法 | executeInSandbox:brace 展开 + sandboxBaseDir + shellEscape |
tools/read.go | 修改方法 | executeInSandbox:sandboxBaseDir + shellEscape |
tools/edit.go | 修改方法 | executeInSandbox:sandboxBaseDir 替换硬编码 |
tools/skill.go | 修改方法 | 沙箱路径映射使用 sandboxBaseDir |
tools/path_guard_test.go | 新增测试 | TestSandboxBaseDir + TestShellEscape |
tools/glob_test.go | 新增测试 | TestGlobToFindArgs(含边界 case) |
| Pattern | searchBase | args |
|---|---|---|
*.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' |
"" | "" | "" |
| 输入 | 期望输出 |
|---|---|
hello | hello |
hello world | hello world |
it's | it'\\''s |
" | " |
\ | \ |
$HOME | $HOME |
"" | "" |
| ctx | 期望输出 |
|---|---|
nil | /workspace |
SandboxWorkDir: "/data/ws" | /data/ws |
SandboxWorkDir: "" | /workspace |
运行 go test ./tools/... 确保现有测试不受影响。
| 维度 | 评估 |
|---|---|
| 影响文件数 | 6 个(path_guard.go、glob.go、grep.go、read.go、edit.go、skill.go) |
| 影响工具数 | 5 个(Glob、Grep、Read、Edit、Skill) |
| 新增公共 API | 2 个内部函数(globToFindArgs、sandboxBaseDir),不导出 |
| 对非沙箱模式的影响 | 无。所有修改仅在沙箱相关代码路径内 |
| 对 SubAgent 的影响 | 无。SubAgent 通过 RunInSandbox 接口执行 |
| 向后兼容性 | 完全兼容。仅修复错误行为,不改变接口签名 |
| 回滚方案 | Git revert 即可。所有修改限定在沙箱路径内 |
步骤 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 可并行实施。