Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/tool-execution-toctou.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 工具执行期 TOCTOU 防护设计

## 背景
`WorkspaceSandbox` 在此前版本主要完成“路径边界校验”,执行链为:

`Permission + Sandbox Check -> Tool.Execute(path string)`

这会留下检查与执行之间的 TOCTOU(Time-of-Check to Time-of-Use)窗口:路径在校验后被替换,工具仍可能访问到非预期对象。

## 本次实现
本次将链路升级为:

`Permission + Sandbox Check -> WorkspaceExecutionPlan -> Tool.Execute(plan + args)`

核心变化如下:

1. `WorkspaceSandbox.Check` 不再只返回 `error`,而是返回 `*WorkspaceExecutionPlan`。
2. `ToolManager` 将 plan 透传到 `ToolCallInput.WorkspacePlan`。
3. `filesystem_read_file` / `filesystem_write_file` / `filesystem_edit` / `bash` 在真实执行前调用 `plan.ValidateForExecution()` 复验锚点状态。
4. 工具使用 `tools.ResolveWorkspaceTarget` 统一消费 plan,避免再次仅依赖字符串路径解析。

## 执行期绑定机制
`WorkspaceExecutionPlan` 在 sandbox 阶段记录:

- 规范化后的 workspace root
- 规范化后的最终 target
- 最近存在路径锚点(anchor)
- 锚点快照(模式、大小、修改时间、符号链接目标)

工具执行前会复验:

1. 当前锚点是否仍为同一路径;
2. 锚点快照是否与校验阶段一致;
3. 锚点解析后的真实路径是否仍在 workspace 内。

若任一步失败,返回稳定错误:`workspace target changed before execution` 或 `escapes workspace root via symlink`。

## 当前防护边界
已覆盖:

- 校验后 symlink 被替换导致 read 越界
- 校验后父目录被替换导致 write 越界
- 校验后 bash workdir 被替换导致 cwd 漂移

仍存在限制(已显式记录):

- 未实现跨平台 `openat/no-follow/dirfd` 级别的系统调用原子封装
- 仍非容器级隔离,不替代系统沙箱

本次目标是把“校验结果”显式带入执行期,显著缩小 TOCTOU 窗口,并为后续更强执行器(含 MCP)预留统一接口。
155 changes: 135 additions & 20 deletions internal/security/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
Expand All @@ -22,28 +23,49 @@ func NewWorkspaceSandbox() *WorkspaceSandbox {
}

// Check validates that the action stays within the configured workspace root.
func (s *WorkspaceSandbox) Check(ctx context.Context, action Action) error {
func (s *WorkspaceSandbox) Check(ctx context.Context, action Action) (*WorkspaceExecutionPlan, error) {
if err := ctx.Err(); err != nil {
return err
return nil, err
}
if err := action.Validate(); err != nil {
return err
return nil, err
}

plan, ok, err := buildWorkspacePlan(action)
if err != nil {
return err
return nil, err
}
if !ok {
return nil
return nil, nil
}

return s.validateWorkspacePlan(plan)
}

type workspacePlan struct {
root string
target string
root string
target string
targetType TargetType
actionType ActionType
}

// WorkspaceExecutionPlan binds a validated workspace target to the later
// execution phase.
type WorkspaceExecutionPlan struct {
Root string
Target string
RequestedTarget string
TargetType TargetType
ActionType ActionType
anchorPath string
anchorSnapshot pathSnapshot
}

type pathSnapshot struct {
mode fs.FileMode
size int64
modUnix int64
linkTarget string
}

// buildWorkspacePlan extracts the workspace root and the sandbox-specific target
Expand All @@ -64,9 +86,16 @@ func buildWorkspacePlan(action Action) (workspacePlan, bool, error) {
return workspacePlan{}, false, nil
}

targetType := action.Payload.SandboxTargetType
if targetType == "" {
targetType = action.Payload.TargetType
}

return workspacePlan{
root: root,
target: target,
root: root,
target: target,
targetType: targetType,
actionType: action.Type,
}, true, nil
}

Expand Down Expand Up @@ -121,21 +150,71 @@ func sandboxTarget(action Action) (string, bool) {
// validateWorkspacePlan resolves the canonical workspace root, expands the
// requested target to an absolute path, verifies it stays inside the workspace,
// and then checks the nearest existing path ancestor for symlink escape.
func (s *WorkspaceSandbox) validateWorkspacePlan(plan workspacePlan) error {
func (s *WorkspaceSandbox) validateWorkspacePlan(plan workspacePlan) (*WorkspaceExecutionPlan, error) {
root, err := s.canonicalWorkspaceRoot(plan.root)
if err != nil {
return err
return nil, err
}

target, err := absoluteWorkspaceTarget(root, plan.target)
if err != nil {
return err
return nil, err
}
if !isWithinWorkspace(root, target) {
return fmt.Errorf("security: path %q escapes workspace root", plan.target)
return nil, fmt.Errorf("security: path %q escapes workspace root", plan.target)
}

anchorPath, err := ensureNoSymlinkEscape(root, target, plan.target)
if err != nil {
return nil, err
}
anchorSnapshot, err := capturePathSnapshot(anchorPath)
if err != nil {
return nil, err
}

return &WorkspaceExecutionPlan{
Root: root,
Target: target,
RequestedTarget: plan.target,
TargetType: plan.targetType,
ActionType: plan.actionType,
anchorPath: anchorPath,
anchorSnapshot: anchorSnapshot,
}, nil
}

// ValidateForExecution rechecks the validated workspace anchor before a tool
// touches the filesystem to narrow the TOCTOU window between sandbox check and
// execution.
func (p *WorkspaceExecutionPlan) ValidateForExecution() error {
if p == nil {
return nil
}
if strings.TrimSpace(p.Root) == "" {
return errors.New("security: workspace plan root is empty")
}
if strings.TrimSpace(p.Target) == "" {
return errors.New("security: workspace plan target is empty")
}

currentAnchor, err := nearestExistingPath(p.Root, p.Target)
if err != nil {
return err
}
if !samePathKey(currentAnchor, p.anchorPath) {
return fmt.Errorf("security: workspace target changed before execution: %q", p.RequestedTarget)
}

return ensureNoSymlinkEscape(root, target, plan.target)
currentSnapshot, err := capturePathSnapshot(currentAnchor)
if err != nil {
return err
}
if !p.anchorSnapshot.Equal(currentSnapshot) {
return fmt.Errorf("security: workspace target changed before execution: %q", p.RequestedTarget)
}

return ensureResolvedPathWithinWorkspace(p.Root, currentAnchor, p.RequestedTarget)
}

// canonicalWorkspaceRoot resolves the configured workspace root to a canonical
Expand Down Expand Up @@ -218,29 +297,65 @@ func absoluteWorkspaceTarget(root string, target string) (string, error) {
// sandbox check and the later filesystem operation in the executor. Eliminating
// that window requires executor-level changes such as descriptor-based access or
// no-follow file opening, which are outside this lightweight sandbox layer.
func ensureNoSymlinkEscape(root string, target string, original string) error {
func ensureNoSymlinkEscape(root string, target string, original string) (string, error) {
existingPath, err := nearestExistingPath(root, target)
if err != nil {
return err
return "", err
}
if existingPath == root {
return nil
return root, nil
}

if err := ensureResolvedPathWithinWorkspace(root, existingPath, original); err != nil {
return "", err
}
return existingPath, nil
}

resolved, err := filepath.EvalSymlinks(existingPath)
func ensureResolvedPathWithinWorkspace(root string, candidate string, original string) error {
resolved, err := filepath.EvalSymlinks(candidate)
if err != nil {
return fmt.Errorf("security: resolve symlink %q: %w", existingPath, err)
return fmt.Errorf("security: resolve symlink %q: %w", candidate, err)
}
resolved, err = filepath.Abs(resolved)
if err != nil {
return fmt.Errorf("security: resolve symlink %q: %w", existingPath, err)
return fmt.Errorf("security: resolve symlink %q: %w", candidate, err)
}
if !isWithinWorkspace(root, resolved) {
return fmt.Errorf("security: path %q escapes workspace root via symlink", original)
}
return nil
}

func capturePathSnapshot(path string) (pathSnapshot, error) {
info, err := os.Lstat(path)
if err != nil {
return pathSnapshot{}, fmt.Errorf("security: inspect path %q: %w", path, err)
}

snapshot := pathSnapshot{
mode: info.Mode(),
size: info.Size(),
modUnix: info.ModTime().UnixNano(),
}
if info.Mode()&os.ModeSymlink != 0 {
linkTarget, readErr := os.Readlink(path)
if readErr != nil {
return pathSnapshot{}, fmt.Errorf("security: read symlink %q: %w", path, readErr)
}
snapshot.linkTarget = filepath.Clean(linkTarget)
}
return snapshot, nil
}

// Equal reports whether two path snapshots represent the same path identity.
func (s pathSnapshot) Equal(other pathSnapshot) bool {
return s.mode == other.mode &&
s.size == other.size &&
s.modUnix == other.modUnix &&
s.linkTarget == other.linkTarget
}

func validateTargetVolume(root string, target string) error {
rootVolume := normalizeVolumeName(root)
targetVolume := normalizeVolumeName(target)
Expand Down
Loading
Loading