|
5 | 5 | "errors" |
6 | 6 | "fmt" |
7 | 7 | "log" |
| 8 | + "path/filepath" |
| 9 | + "runtime" |
8 | 10 | "strings" |
9 | 11 | "sync" |
10 | 12 | "time" |
|
68 | 70 | ErrCapabilityDenied = errors.New("tools: capability denied") |
69 | 71 | ) |
70 | 72 |
|
| 73 | +const ( |
| 74 | + // sandboxExternalWriteApprovalRuleID 是工作区外低风险写入的审批规则标识。 |
| 75 | + sandboxExternalWriteApprovalRuleID = "workspace-sandbox:external-write-ask" |
| 76 | + // sandboxExternalWriteApprovalReason 是工作区外低风险写入需要审批时的统一提示。 |
| 77 | + sandboxExternalWriteApprovalReason = "workspace write outside workdir requires approval" |
| 78 | +) |
| 79 | + |
71 | 80 | // PermissionDecisionError reports a non-allow permission decision. |
72 | 81 | type PermissionDecisionError struct { |
73 | 82 | decision security.Decision |
@@ -322,19 +331,297 @@ func (m *DefaultManager) Execute(ctx context.Context, input ToolCallInput) (Tool |
322 | 331 | return result, permissionErrorFromDecision(decision) |
323 | 332 | } |
324 | 333 |
|
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 | + } |
331 | 351 | m.auditCapabilityDecision(action, string(security.DecisionAllow), "") |
332 | 352 |
|
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 |
335 | 365 | } |
336 | 366 |
|
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() |
338 | 625 | } |
339 | 626 |
|
340 | 627 | // verifyCapabilityToken 校验 capability token 的签名、绑定关系与时效性。 |
|
0 commit comments