package sandbox import ( "os" "path/filepath" "strings" ) // Explanation is structured data extracted from a ViolationReport. It carries // only domain facts — the human-readable rendering and any CLI-specific // suggestion strings (flag names, line layout) belong to the presentation // layer (see internal/ui). type Explanation struct { // Primary is the violation most likely to be actionable, chosen by // scoreViolation. Nil when the report contains no violations. Primary *Violation // Override carries a structured override hint for the primary violation // (kind + target) when one can be safely suggested. Nil when no safe // suggestion is available (glob targets, control characters, unsupported // violation kinds). The presentation layer decides how to render this as a // CLI flag, an API hint, etc. Override *OverrideSuggestion // AdditionalDenials counts violations beyond Primary so callers can show // "+N more" without re-walking the report. AdditionalDenials int } // OverrideSuggestion is the structured form of a "you can re-run with this // allowance" hint. Kind tells the consumer which permission would unblock the // operation; Target is the path/exec the user would whitelist. type OverrideSuggestion struct { Kind ViolationKind Target string } // BuildExplanation produces an Explanation for the given report. func BuildExplanation(report *ViolationReport) Explanation { primary := primaryViolation(report) exp := Explanation{Primary: primary} if primary != nil { exp.Override = overrideSuggestion(*primary) if report != nil && len(report.Violations) > 1 { exp.AdditionalDenials = len(report.Violations) - 1 } } return exp } func overrideSuggestion(v Violation) *OverrideSuggestion { if !isSafeOverrideTarget(v.Target) { return nil } switch v.Kind { case ViolationKindFSRead, ViolationKindFSWrite, ViolationKindFSDeleteOrRename, ViolationKindExec: return &OverrideSuggestion{Kind: v.Kind, Target: v.Target} default: return nil } } func primaryViolation(report *ViolationReport) *Violation { if report == nil || len(report.Violations) == 0 { return nil } cwd, _ := os.Getwd() bestIdx := 0 bestScore := scoreViolation(report.SandboxName, report.Violations[0], cwd) for i := 1; i < len(report.Violations); i++ { score := scoreViolation(report.SandboxName, report.Violations[i], cwd) if score >= bestScore { bestIdx = i bestScore = score } } return &report.Violations[bestIdx] } func scoreViolation(driver DriverName, v Violation, cwd string) int { score := 0 switch v.Kind { case ViolationKindFSRead, ViolationKindFSWrite: score += 120 case ViolationKindExec: score += 110 case ViolationKindNetworkBind, ViolationKindNetworkConnect: score += 105 case ViolationKindFSDeleteOrRename: score += 100 case ViolationKindGenericDeny: score += 10 default: score += 30 } if isSafeOverrideTarget(v.Target) { score += 40 } if v.Target != "" && v.Target != v.RuleTarget { score += 20 } if isProjectPath(v.Target, cwd) { score += 80 } if isSensitiveProjectFile(v.Target) { score += 60 } if isNoisySystemPath(driver, v.Target) { score -= 120 } if v.Kind == ViolationKindGenericDeny && v.Target == "" { score -= 40 } return score } func isSafeOverrideTarget(value string) bool { if value == "" { return false } if strings.ContainsAny(value, "*?[]") { return false } for _, r := range value { if r == 0 || r < 0x20 || r == 0x7f { return false } } return true } func isProjectPath(target, cwd string) bool { if target == "" || cwd == "" { return false } if strings.HasPrefix(target, ".") { return !isParentRelativePath(target) } cleanTarget := filepath.Clean(target) cleanCwd := filepath.Clean(cwd) return cleanTarget == cleanCwd || strings.HasPrefix(cleanTarget, cleanCwd+string(filepath.Separator)) } // IsSensitiveProjectTarget reports whether target points to a known sensitive // file or directory (credential stores, dotfiles like .env/.npmrc). Used by // the overlay save-time guard to refuse implicit credential allowances. func IsSensitiveProjectTarget(target string) bool { return isSensitiveProjectFile(target) } func isSensitiveProjectFile(target string) bool { if target == "" || isParentRelativePath(target) { return false } base := filepath.Base(target) switch { case strings.HasPrefix(base, ".env"): return true case base == ".npmrc", base == ".pypirc", base == ".netrc": return true case base == ".aws", base == ".ssh", base == ".kube", base == ".gnupg": return true default: return strings.Contains(target, string(filepath.Separator)+".ssh") || strings.Contains(target, string(filepath.Separator)+".aws") || strings.Contains(target, string(filepath.Separator)+".kube") || strings.Contains(target, string(filepath.Separator)+".gnupg") } } func isParentRelativePath(target string) bool { cleanTarget := filepath.Clean(target) return cleanTarget == ".." || strings.HasPrefix(cleanTarget, ".."+string(filepath.Separator)) } func isNoisySystemPath(driver DriverName, target string) bool { if driver != DriverSeatbelt { return false } switch target { case "/dev/dtracehelper", "/dev/tty": return true default: return strings.HasPrefix(target, "/dev/ttys") } }