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
2 changes: 1 addition & 1 deletion internal/bootstrap/build_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func buildToolRegistry(
todoWriteTool := tools.NewTodoWriteTool(store, func() string { return *sessionIDRef })

toolList := []tools.Tool{
tools.NewReadTool(ws),
tools.NewReadTool(ws, policy),
tools.NewWriteTool(ws),
tools.NewEditTool(ws),
tools.NewListTool(ws),
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ func Default() Config {
"pnpm test*": "allow",
"yarn test*": "allow",
},
ExternalDir: "deny",
ExternalDir: "ask",
},
Workflow: WorkflowConfig{
RequireTodoForComplex: true,
Expand Down
7 changes: 7 additions & 0 deletions internal/permission/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,13 +254,15 @@ func PresetConfig(name string) (config.PermissionConfig, bool) {
Default: "ask", Read: "allow", Edit: "ask", Write: "ask", List: "allow", Glob: "allow", Grep: "allow", Patch: "ask",
LSPDiagnostics: "allow", LSPDefinition: "allow", LSPHover: "allow",
TodoRead: "allow", TodoWrite: "allow", Skill: "ask", Task: "ask", Fetch: "ask",
ExternalDir: "ask",
Bash: map[string]string{"*": "ask", "ls *": "allow", "cat *": "allow", "grep *": "allow", "go test *": "allow", "pytest*": "allow", "npm test*": "allow", "pnpm test*": "allow", "yarn test*": "allow"},
}, true
case "plan":
return config.PermissionConfig{
Default: "ask", Read: "allow", Edit: "deny", Write: "deny", List: "allow", Glob: "allow", Grep: "allow", Patch: "deny",
LSPDiagnostics: "allow", LSPDefinition: "allow", LSPHover: "allow",
TodoRead: "allow", TodoWrite: "allow", Skill: "allow", Task: "deny", Fetch: "allow", Question: "allow",
ExternalDir: "ask",
Bash: map[string]string{
"*": "ask",
"ls": "allow",
Expand Down Expand Up @@ -296,3 +298,8 @@ func (p *Policy) ApplyPreset(name string) bool {
p.cfg = cfg
return true
}

// ExternalDirDecision 返回外部目录访问权限决策
func (p *Policy) ExternalDirDecision() Decision {
return normalizeDecision(p.cfg.ExternalDir, DecisionAsk)
}
144 changes: 138 additions & 6 deletions internal/tools/read.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,81 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"

"coder/internal/chat"
"coder/internal/permission"
"coder/internal/security"
)

type ReadTool struct {
ws *security.Workspace
ws *security.Workspace
policy *permission.Policy
}

func NewReadTool(ws *security.Workspace) *ReadTool {
return &ReadTool{ws: ws}
func NewReadTool(ws *security.Workspace, policy *permission.Policy) *ReadTool {
return &ReadTool{ws: ws, policy: policy}
}

func (t *ReadTool) Name() string {
return "read"
}

// ApprovalRequest 实现 ApprovalAware 接口,检查是否需要审批外部路径
// ApprovalRequest implements ApprovalAware interface to check if external path needs approval
func (t *ReadTool) ApprovalRequest(args json.RawMessage) (*ApprovalRequest, error) {
var in struct {
Path string `json:"path"`
}
if err := json.Unmarshal(args, &in); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}

path := strings.TrimSpace(in.Path)
if path == "" {
return nil, nil
}

// 解析路径(展开 ~)
if strings.HasPrefix(path, "~") {
expanded, err := t.expandHomePath(path)
if err != nil {
return nil, nil // 解析失败,让 Execute 处理错误
}
path = expanded
}

// 如果不是绝对路径,不需要审批(相对路径限制在 workspace 内)
if !filepath.IsAbs(path) {
return nil, nil
}

// 检查是否在 workspace 内
rel, err := filepath.Rel(t.ws.Root(), path)
if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
// 在 workspace 内,不需要审批
return nil, nil
}

// 在 workspace 外,检查权限策略
decision := t.policy.ExternalDirDecision()
switch decision {
case permission.DecisionAllow:
// 允许,不需要审批
return nil, nil
case permission.DecisionDeny:
// 拒绝,不需要审批(直接拒绝)
return nil, nil
default:
// ask,需要审批
return &ApprovalRequest{
Tool: t.Name(),
Reason: fmt.Sprintf("read external path: %s", in.Path),
}, nil
}
}

func (t *ReadTool) Definition() chat.ToolDef {
return chat.ToolDef{
Type: "function",
Expand Down Expand Up @@ -75,9 +132,9 @@ func (t *ReadTool) Execute(_ context.Context, args json.RawMessage) (string, err
if in.Limit > maxLimit {
in.Limit = maxLimit
}
resolved, err := t.ws.Resolve(in.Path)
if err != nil {
return "", fmt.Errorf("resolve path: %w", err)
resolved, resolveErr := t.resolvePath(in.Path)
if resolveErr != nil {
return "", fmt.Errorf("resolve path: %w", resolveErr)
}
f, err := os.Open(resolved)
if err != nil {
Expand Down Expand Up @@ -153,3 +210,78 @@ func (t *ReadTool) Execute(_ context.Context, args json.RawMessage) (string, err
"has_more": hasMore,
}), nil
}

// resolvePath 统一处理路径解析,支持相对路径、绝对路径和 ~ 路径
// resolvePath handles path resolution for relative, absolute and ~ paths
func (t *ReadTool) resolvePath(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", fmt.Errorf("empty path")
}

// 1. 处理 ~ 路径:展开为家目录绝对路径
// Handle ~ path: expand to home directory
if strings.HasPrefix(path, "~") {
expanded, err := t.expandHomePath(path)
if err != nil {
return "", err
}
path = expanded
}

// 2. 如果是绝对路径,检查外部权限
// If absolute path, check external permission
if filepath.IsAbs(path) {
return t.checkExternalPath(path)
}

// 3. 相对路径:限制在 workspace 内
// Relative path: restrict to workspace
return t.ws.Resolve(path)
}

// expandHomePath 将 ~ 展开为家目录绝对路径
// expandHomePath expands ~ to home directory absolute path
func (t *ReadTool) expandHomePath(path string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home dir: %w", err)
}

if path == "~" {
return home, nil
}
if strings.HasPrefix(path, "~/") {
return filepath.Join(home, strings.TrimPrefix(path, "~/")), nil
}
// ~username 格式暂不支持 / ~username format not supported
return "", fmt.Errorf("unsupported path format: %s", path)
}

// checkExternalPath 检查外部路径权限
// checkExternalPath checks external path permission
// 注意:当策略为 ask 时,如果 Execute 被调用,说明审批已通过
// Note: when policy is ask, if Execute is called, approval has been granted
func (t *ReadTool) checkExternalPath(absPath string) (string, error) {
// 检查路径是否在 workspace 内 / Check if path is inside workspace
rel, err := filepath.Rel(t.ws.Root(), absPath)
if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
// 路径在 workspace 内,直接允许 / Path inside workspace, allow directly
return absPath, nil
}

// 路径在 workspace 外,检查外部路径权限 / Path outside workspace, check permission
decision := t.policy.ExternalDirDecision()
switch decision {
case permission.DecisionAllow:
// 明确允许 / Explicitly allowed
return absPath, nil
case permission.DecisionDeny:
// 明确拒绝 / Explicitly denied
return "", fmt.Errorf("external path access denied by policy")
default:
// ask 策略:如果 Execute 被调用,说明审批已通过
// ask policy: if Execute is called, approval has been granted
return absPath, nil
}
}
140 changes: 136 additions & 4 deletions internal/tools/read_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"strings"
"testing"

"coder/internal/config"
"coder/internal/permission"
"coder/internal/security"
)

Expand All @@ -28,7 +30,9 @@ func TestReadToolSmallFileDefaultLimit(t *testing.T) {
if err != nil {
t.Fatal(err)
}
tool := NewReadTool(ws)
cfg, _ := permission.PresetConfig("build")
policy := permission.New(cfg)
tool := NewReadTool(ws, policy)

// 仅传 path,不带 offset/limit,期望读出全部 10 行
args, _ := json.Marshal(map[string]any{
Expand Down Expand Up @@ -77,7 +81,9 @@ func TestReadToolLargeFilePagination(t *testing.T) {
if err != nil {
t.Fatal(err)
}
tool := NewReadTool(ws)
cfg, _ := permission.PresetConfig("build")
policy := permission.New(cfg)
tool := NewReadTool(ws, policy)

// 默认只读前 50 行
args, _ := json.Marshal(map[string]any{
Expand Down Expand Up @@ -126,7 +132,9 @@ func TestReadToolOffsetAndLimit(t *testing.T) {
if err != nil {
t.Fatal(err)
}
tool := NewReadTool(ws)
cfg, _ := permission.PresetConfig("build")
policy := permission.New(cfg)
tool := NewReadTool(ws, policy)

// 从第 51 行开始读 50 行
args, _ := json.Marshal(map[string]any{
Expand Down Expand Up @@ -172,7 +180,9 @@ func TestReadToolOffsetBeyondEOFAndInvalidLimit(t *testing.T) {
if err != nil {
t.Fatal(err)
}
tool := NewReadTool(ws)
cfg, _ := permission.PresetConfig("build")
policy := permission.New(cfg)
tool := NewReadTool(ws, policy)

// offset 大于文件总行数,期望 content 为空、has_more=false
argsBeyond, _ := json.Marshal(map[string]any{
Expand Down Expand Up @@ -204,3 +214,125 @@ func TestReadToolOffsetBeyondEOFAndInvalidLimit(t *testing.T) {
t.Fatalf("execute read (invalid limit): %v", err)
}
}

// TestReadToolExternalPathApproval 测试外部路径审批流程
func TestReadToolExternalPathApproval(t *testing.T) {
// 创建一个临时目录作为 workspace
wsRoot := t.TempDir()
ws, err := security.NewWorkspace(wsRoot)
if err != nil {
t.Fatal(err)
}

// 在工作区外创建一个文件
externalDir := t.TempDir()
externalFile := filepath.Join(externalDir, "external.txt")
externalContent := "external file content"
if err := os.WriteFile(externalFile, []byte(externalContent), 0o644); err != nil {
t.Fatal(err)
}

// 测试策略为 ask 时,ApprovalRequest 应该返回需要审批
t.Run("ask_policy_returns_approval_request", func(t *testing.T) {
cfg := config.PermissionConfig{ExternalDir: "ask"}
policy := permission.New(cfg)
tool := NewReadTool(ws, policy)

args, _ := json.Marshal(map[string]any{
"path": externalFile,
})

req, err := tool.ApprovalRequest(args)
if err != nil {
t.Fatalf("ApprovalRequest error: %v", err)
}
if req == nil {
t.Fatal("expected ApprovalRequest for external path, got nil")
}
if req.Tool != "read" {
t.Fatalf("expected tool name 'read', got %q", req.Tool)
}
})

// 测试策略为 allow 时,ApprovalRequest 应该返回 nil
t.Run("allow_policy_no_approval_request", func(t *testing.T) {
cfg := config.PermissionConfig{ExternalDir: "allow"}
policy := permission.New(cfg)
tool := NewReadTool(ws, policy)

args, _ := json.Marshal(map[string]any{
"path": externalFile,
})

req, err := tool.ApprovalRequest(args)
if err != nil {
t.Fatalf("ApprovalRequest error: %v", err)
}
if req != nil {
t.Fatalf("expected no ApprovalRequest for allow policy, got %v", req)
}
})

// 测试策略为 deny 时,ApprovalRequest 应该返回 nil(直接拒绝)
t.Run("deny_policy_no_approval_request", func(t *testing.T) {
cfg := config.PermissionConfig{ExternalDir: "deny"}
policy := permission.New(cfg)
tool := NewReadTool(ws, policy)

args, _ := json.Marshal(map[string]any{
"path": externalFile,
})

req, err := tool.ApprovalRequest(args)
if err != nil {
t.Fatalf("ApprovalRequest error: %v", err)
}
if req != nil {
t.Fatalf("expected no ApprovalRequest for deny policy, got %v", req)
}
})

// 测试 ~ 路径展开和审批
t.Run("home_path_expansion_and_approval", func(t *testing.T) {
cfg := config.PermissionConfig{ExternalDir: "ask"}
policy := permission.New(cfg)
tool := NewReadTool(ws, policy)

args, _ := json.Marshal(map[string]any{
"path": "~/test_file.txt",
})

req, err := tool.ApprovalRequest(args)
if err != nil {
t.Fatalf("ApprovalRequest error: %v", err)
}
if req == nil {
t.Fatal("expected ApprovalRequest for ~ path, got nil")
}
})

// 测试工作区内路径不需要审批
t.Run("workspace_path_no_approval", func(t *testing.T) {
cfg := config.PermissionConfig{ExternalDir: "ask"}
policy := permission.New(cfg)
tool := NewReadTool(ws, policy)

// 在工作区内创建文件
internalFile := filepath.Join(wsRoot, "internal.txt")
if err := os.WriteFile(internalFile, []byte("internal content"), 0o644); err != nil {
t.Fatal(err)
}

args, _ := json.Marshal(map[string]any{
"path": "internal.txt",
})

req, err := tool.ApprovalRequest(args)
if err != nil {
t.Fatalf("ApprovalRequest error: %v", err)
}
if req != nil {
t.Fatalf("expected no ApprovalRequest for workspace path, got %v", req)
}
})
}
Loading
Loading