Skip to content

Commit 6a4f63b

Browse files
authored
Merge pull request #387 from Cai-Tang-www/fix/tool-sandbox
fix(tools): route low-risk external writes through approval ask
2 parents db1b34e + 7d5710d commit 6a4f63b

2 files changed

Lines changed: 674 additions & 9 deletions

File tree

internal/tools/manager.go

Lines changed: 296 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"errors"
66
"fmt"
77
"log"
8+
"path/filepath"
9+
"runtime"
810
"strings"
911
"sync"
1012
"time"
@@ -68,6 +70,13 @@ var (
6870
ErrCapabilityDenied = errors.New("tools: capability denied")
6971
)
7072

73+
const (
74+
// sandboxExternalWriteApprovalRuleID 是工作区外低风险写入的审批规则标识。
75+
sandboxExternalWriteApprovalRuleID = "workspace-sandbox:external-write-ask"
76+
// sandboxExternalWriteApprovalReason 是工作区外低风险写入需要审批时的统一提示。
77+
sandboxExternalWriteApprovalReason = "workspace write outside workdir requires approval"
78+
)
79+
7180
// PermissionDecisionError reports a non-allow permission decision.
7281
type PermissionDecisionError struct {
7382
decision security.Decision
@@ -322,19 +331,297 @@ func (m *DefaultManager) Execute(ctx context.Context, input ToolCallInput) (Tool
322331
return result, permissionErrorFromDecision(decision)
323332
}
324333

325-
plan, err := m.sandbox.Check(ctx, action)
326-
if err != nil {
327-
result := NewErrorResult(input.Name, "workspace sandbox rejected action", err.Error(), actionMetadata(action))
328-
result.ToolCallID = input.ID
329-
return result, err
330-
}
334+
plan, err := m.sandbox.Check(ctx, action)
335+
if err != nil {
336+
if decision, decisionMatched := resolveSandboxOutsideWriteDecision(input, action, err, m.sessionDecisions); decisionMatched {
337+
if decision.Decision != security.DecisionAllow {
338+
result := blockedToolResult(input, decision)
339+
return result, permissionErrorFromDecision(decision)
340+
}
341+
m.auditCapabilityDecision(action, string(security.DecisionAllow), decision.Reason)
342+
return m.executor.Execute(ctx, input)
343+
} else {
344+
result := NewErrorResult(input.Name, "workspace sandbox rejected action", sandboxErrorDetails(action, err), actionMetadata(action))
345+
result.ToolCallID = input.ID
346+
return result, err
347+
}
348+
} else if plan != nil {
349+
input.WorkspacePlan = plan
350+
}
331351
m.auditCapabilityDecision(action, string(security.DecisionAllow), "")
332352

333-
if plan != nil {
334-
input.WorkspacePlan = plan
353+
return m.executor.Execute(ctx, input)
354+
}
355+
356+
// resolveSandboxOutsideWriteDecision 将“工作区外低风险写入”沙箱拒绝收敛为 ask/remembered allow/remembered deny。
357+
func resolveSandboxOutsideWriteDecision(
358+
input ToolCallInput,
359+
action security.Action,
360+
sandboxErr error,
361+
sessionMemory *sessionPermissionMemory,
362+
) (security.CheckResult, bool) {
363+
if !isSandboxOutsideWriteApprovalCandidate(action, sandboxErr) {
364+
return security.CheckResult{}, false
335365
}
336366

337-
return m.executor.Execute(ctx, input)
367+
decision := security.CheckResult{
368+
Decision: security.DecisionAsk,
369+
Action: action,
370+
Rule: &security.Rule{
371+
ID: sandboxExternalWriteApprovalRuleID,
372+
Type: action.Type,
373+
Resource: action.Payload.Resource,
374+
Decision: security.DecisionAsk,
375+
Reason: sandboxExternalWriteApprovalReason,
376+
},
377+
Reason: sandboxExternalWriteApprovalReason,
378+
}
379+
380+
if sessionMemory != nil {
381+
if rememberedDecision, rememberedScope, ok := sessionMemory.resolve(input.SessionID, action); ok {
382+
decision = security.CheckResult{
383+
Decision: rememberedDecision,
384+
Action: action,
385+
Rule: &security.Rule{
386+
ID: "session-memory:" + string(rememberedScope),
387+
Type: action.Type,
388+
Resource: action.Payload.Resource,
389+
Decision: rememberedDecision,
390+
Reason: sessionDecisionReason(rememberedScope),
391+
},
392+
Reason: sessionDecisionReason(rememberedScope),
393+
}
394+
}
395+
}
396+
397+
return decision, true
398+
}
399+
400+
// isSandboxOutsideWriteApprovalCandidate 判断当前沙箱错误是否可升级为“工作区外低风险写入审批”。
401+
func isSandboxOutsideWriteApprovalCandidate(action security.Action, sandboxErr error) bool {
402+
if isWorkspaceSymlinkViolationError(sandboxErr) {
403+
return false
404+
}
405+
if !isWorkspaceBoundaryViolationError(sandboxErr) {
406+
return false
407+
}
408+
if action.Type != security.ActionTypeWrite {
409+
return false
410+
}
411+
resource := strings.TrimSpace(strings.ToLower(action.Payload.Resource))
412+
toolName := strings.TrimSpace(strings.ToLower(action.Payload.ToolName))
413+
if resource != ToolNameFilesystemWriteFile && toolName != ToolNameFilesystemWriteFile {
414+
return false
415+
}
416+
417+
targetPath := resolveActionSandboxTargetPath(action)
418+
if targetPath == "" {
419+
return false
420+
}
421+
return isLowRiskExternalWritePath(targetPath)
422+
}
423+
424+
// isWorkspaceBoundaryViolationError 判断错误是否由工作区边界校验触发。
425+
func isWorkspaceBoundaryViolationError(err error) bool {
426+
message := strings.ToLower(strings.TrimSpace(errorMessage(err)))
427+
if message == "" {
428+
return false
429+
}
430+
return strings.Contains(message, "escapes workspace root") ||
431+
strings.Contains(message, "different volume than workspace root")
432+
}
433+
434+
// isWorkspaceSymlinkViolationError 判断沙箱拒绝是否来自符号链接越界逃逸。
435+
func isWorkspaceSymlinkViolationError(err error) bool {
436+
message := strings.ToLower(strings.TrimSpace(errorMessage(err)))
437+
if message == "" {
438+
return false
439+
}
440+
return strings.Contains(message, "escapes workspace root via symlink")
441+
}
442+
443+
// resolveActionSandboxTargetPath 将 action 的 sandbox target 解析为可判定风险的绝对路径。
444+
func resolveActionSandboxTargetPath(action security.Action) string {
445+
target := strings.TrimSpace(action.Payload.SandboxTarget)
446+
if target == "" {
447+
target = strings.TrimSpace(action.Payload.Target)
448+
}
449+
if target == "" {
450+
return ""
451+
}
452+
if !filepath.IsAbs(target) && strings.TrimSpace(action.Payload.Workdir) != "" {
453+
target = filepath.Join(strings.TrimSpace(action.Payload.Workdir), target)
454+
}
455+
if absoluteTarget, err := filepath.Abs(target); err == nil {
456+
target = absoluteTarget
457+
}
458+
return filepath.Clean(target)
459+
}
460+
461+
// isLowRiskExternalWritePath 判断工作区外写入目标是否属于可审批放行的低风险路径。
462+
func isLowRiskExternalWritePath(targetPath string) bool {
463+
cleaned := strings.TrimSpace(filepath.Clean(targetPath))
464+
if cleaned == "" || cleaned == "." {
465+
return false
466+
}
467+
if isSystemProtectedPath(cleaned) {
468+
return false
469+
}
470+
if isUserStartupProfilePath(cleaned) {
471+
return false
472+
}
473+
if isHighRiskExecutableExtension(filepath.Ext(cleaned)) {
474+
return false
475+
}
476+
return true
477+
}
478+
479+
// isUserStartupProfilePath 判断路径是否命中用户级 shell/profile 启动文件,命中后必须保持硬拒绝。
480+
func isUserStartupProfilePath(path string) bool {
481+
return isUserStartupProfilePathForOS(path, runtime.GOOS)
482+
}
483+
484+
// isUserStartupProfilePathForOS 按指定操作系统判定路径是否命中用户级 shell/profile 启动文件。
485+
func isUserStartupProfilePathForOS(path string, goos string) bool {
486+
cleaned := strings.ToLower(strings.TrimSpace(filepath.Clean(path)))
487+
if cleaned == "" || cleaned == "." {
488+
return false
489+
}
490+
491+
base := filepath.Base(cleaned)
492+
switch base {
493+
case ".bashrc", ".bash_profile", ".bash_login", ".profile",
494+
".zshrc", ".zprofile", ".zlogin", ".zshenv", ".cshrc", ".tcshrc",
495+
"profile.ps1", "microsoft.powershell_profile.ps1",
496+
"microsoft.vscode_profile.ps1", "profile":
497+
return true
498+
}
499+
500+
segments := splitPathSegments(cleaned)
501+
if len(segments) == 0 {
502+
return false
503+
}
504+
if strings.EqualFold(strings.TrimSpace(goos), "windows") {
505+
for i := 0; i+2 < len(segments); i++ {
506+
if segments[i] == "documents" && segments[i+1] == "windowspowershell" && strings.HasSuffix(base, ".ps1") {
507+
return true
508+
}
509+
if segments[i] == "documents" && segments[i+1] == "powershell" && strings.HasSuffix(base, ".ps1") {
510+
return true
511+
}
512+
}
513+
return false
514+
}
515+
for i := 0; i+2 < len(segments); i++ {
516+
if segments[i] == ".config" && segments[i+1] == "fish" && base == "config.fish" {
517+
return true
518+
}
519+
}
520+
return false
521+
}
522+
523+
// isSystemProtectedPath 判定路径是否命中系统受保护目录,命中后必须保持硬拒绝。
524+
func isSystemProtectedPath(path string) bool {
525+
return isSystemProtectedPathForOS(path, runtime.GOOS)
526+
}
527+
528+
// isSystemProtectedPathForOS 按指定操作系统判定路径是否命中系统受保护目录。
529+
func isSystemProtectedPathForOS(path string, goos string) bool {
530+
normalized := strings.ToLower(filepath.Clean(path))
531+
if strings.EqualFold(strings.TrimSpace(goos), "windows") {
532+
volume := strings.ToLower(filepath.VolumeName(normalized))
533+
if volume == "" && len(normalized) >= 2 && normalized[1] == ':' {
534+
volume = normalized[:2]
535+
}
536+
rest := strings.TrimPrefix(normalized, volume)
537+
rest = strings.TrimLeft(rest, `\/`)
538+
if rest == "" {
539+
return true
540+
}
541+
segments := splitPathSegments(rest)
542+
switch segments[0] {
543+
case "windows", "program files", "program files (x86)", "programdata",
544+
"$recycle.bin", "system volume information", "recovery", "boot":
545+
return true
546+
}
547+
if len(segments) >= 3 && segments[0] == "users" && segments[2] == "appdata" {
548+
return true
549+
}
550+
} else {
551+
trimmed := strings.TrimLeft(normalized, "/")
552+
segments := splitPathSegments(trimmed)
553+
if len(segments) == 0 {
554+
return true
555+
}
556+
switch segments[0] {
557+
case "etc", "bin", "sbin", "usr", "var", "lib", "lib64", "boot", "proc", "sys", "dev", "run", "root":
558+
return true
559+
}
560+
}
561+
562+
for _, segment := range splitPathSegments(normalized) {
563+
if segment == ".ssh" {
564+
return true
565+
}
566+
}
567+
return false
568+
}
569+
570+
// isHighRiskExecutableExtension 识别高风险可执行文件后缀,命中后不走审批放行链路。
571+
func isHighRiskExecutableExtension(extension string) bool {
572+
switch strings.ToLower(strings.TrimSpace(extension)) {
573+
case ".exe", ".dll", ".sys", ".bat", ".cmd", ".com", ".scr", ".msi", ".reg":
574+
return true
575+
default:
576+
return false
577+
}
578+
}
579+
580+
// splitPathSegments 把路径按目录分隔符拆成稳定片段,忽略空片段。
581+
func splitPathSegments(path string) []string {
582+
normalized := strings.ReplaceAll(path, "\\", "/")
583+
rawSegments := strings.Split(normalized, "/")
584+
segments := make([]string, 0, len(rawSegments))
585+
for _, segment := range rawSegments {
586+
trimmed := strings.TrimSpace(segment)
587+
if trimmed == "" {
588+
continue
589+
}
590+
segments = append(segments, trimmed)
591+
}
592+
return segments
593+
}
594+
595+
// sandboxErrorDetails 生成可回灌给模型的沙箱拒绝详情,便于模型正确感知失败原因。
596+
func sandboxErrorDetails(action security.Action, sandboxErr error) string {
597+
securityMessage := strings.TrimSpace(errorMessage(sandboxErr))
598+
if securityMessage == "" {
599+
securityMessage = "sandbox rejected action"
600+
}
601+
if !strings.HasPrefix(strings.ToLower(securityMessage), "security:") {
602+
securityMessage = "security: " + securityMessage
603+
}
604+
parts := []string{
605+
securityMessage,
606+
}
607+
if workdir := strings.TrimSpace(action.Payload.Workdir); workdir != "" {
608+
parts = append(parts, "workdir: "+workdir)
609+
}
610+
if target := strings.TrimSpace(action.Payload.Target); target != "" {
611+
parts = append(parts, "target: "+target)
612+
}
613+
if sandboxTarget := strings.TrimSpace(action.Payload.SandboxTarget); sandboxTarget != "" {
614+
parts = append(parts, "sandbox_target: "+sandboxTarget)
615+
}
616+
return strings.Join(parts, "\n")
617+
}
618+
619+
// errorMessage 提取错误文本,统一处理 nil 输入避免重复分支。
620+
func errorMessage(err error) string {
621+
if err == nil {
622+
return ""
623+
}
624+
return err.Error()
338625
}
339626

340627
// verifyCapabilityToken 校验 capability token 的签名、绑定关系与时效性。

0 commit comments

Comments
 (0)