| 1 | package evidence |
| 2 | |
| 3 | import ( |
| 4 | "path/filepath" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | // RiskLevel classifies the latest post-mutation change set for adaptive review. |
| 9 | type RiskLevel string |
| 10 | |
| 11 | const ( |
| 12 | RiskLow RiskLevel = "low" |
| 13 | RiskMedium RiskLevel = "medium" |
| 14 | RiskHigh RiskLevel = "high" |
| 15 | ) |
| 16 | |
| 17 | // highRiskPathHints elevate ordinary production edits to High when the path |
| 18 | // touches auth, crypto, networking, providers, plugins, sandbox, config, |
| 19 | // migrations, persistence, or concurrency. |
| 20 | var highRiskPathHints = []string{ |
| 21 | "auth", "permission", "secret", "credential", "token", "password", "oauth", |
| 22 | "crypto", "encrypt", "decrypt", "tls", "ssl", "keyring", |
| 23 | "network", "proxy", "http", "websocket", "provider", |
| 24 | "plugin", "mcp", "tool", "schema", "sandbox", |
| 25 | "config", "migrate", "migration", "persist", "store", "database", "db", |
| 26 | "concurrent", "mutex", "race", "lock", "atomic", |
| 27 | } |
| 28 | |
| 29 | // highRiskToolHints elevate opaque or privileged mutation surfaces. |
| 30 | var highRiskToolHints = []string{ |
| 31 | "mcp__", "install_source", "install_skill", "plugin", |
| 32 | } |
| 33 | |
| 34 | // ClassifyMutationRisk scores the change set after the latest mutation. |
| 35 | // Low: docs/tests/i18n/pure presentation only, with no opaque writes. |
| 36 | // Medium: ordinary production code or limited multi-file edits. |
| 37 | // High: security-sensitive surfaces, opaque mutations, or 10+ paths. |
| 38 | func ClassifyMutationRisk(receipts []Receipt, after int) RiskLevel { |
| 39 | start := after + 1 |
| 40 | if start < 0 { |
| 41 | start = 0 |
| 42 | } |
| 43 | var paths []string |
| 44 | seen := map[string]bool{} |
| 45 | opaque := false |
| 46 | hasProd := false |
| 47 | onlyLow := true |
| 48 | |
| 49 | // Include the mutation receipt itself. |
| 50 | if after >= 0 && after < len(receipts) { |
| 51 | r := receipts[after] |
| 52 | if r.Success && r.Mutation { |
| 53 | if len(r.Paths) == 0 { |
| 54 | opaque = true |
| 55 | } |
| 56 | for _, p := range r.Paths { |
| 57 | if !seen[p] { |
| 58 | seen[p] = true |
| 59 | paths = append(paths, p) |
| 60 | } |
| 61 | } |
| 62 | if toolLooksHighRisk(r.ToolName) { |
| 63 | return RiskHigh |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | for i := start; i < len(receipts); i++ { |
| 68 | r := receipts[i] |
| 69 | if !r.Success || !r.Mutation { |
| 70 | continue |
| 71 | } |
| 72 | if len(r.Paths) == 0 { |
| 73 | opaque = true |
| 74 | } |
| 75 | if toolLooksHighRisk(r.ToolName) { |
| 76 | return RiskHigh |
| 77 | } |
| 78 | for _, p := range r.Paths { |
| 79 | if !seen[p] { |
| 80 | seen[p] = true |
| 81 | paths = append(paths, p) |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | if opaque { |
| 86 | return RiskHigh |
| 87 | } |
| 88 | if len(paths) == 0 { |
| 89 | return RiskLow |
| 90 | } |
| 91 | if len(paths) >= 10 { |
| 92 | return RiskHigh |
| 93 | } |
| 94 | for _, p := range paths { |
| 95 | if pathLooksHighRisk(p) { |
| 96 | return RiskHigh |
| 97 | } |
| 98 | if !pathLooksLowRisk(p) { |
| 99 | onlyLow = false |
| 100 | hasProd = true |
| 101 | } |
| 102 | } |
| 103 | if onlyLow && !hasProd { |
| 104 | return RiskLow |
| 105 | } |
| 106 | return RiskMedium |
| 107 | } |
| 108 | |
| 109 | // MutationRiskAfter classifies risk from the ledger using the latest mutation. |
| 110 | func (l *Ledger) MutationRiskAfter(after int) RiskLevel { |
| 111 | if l == nil { |
| 112 | return RiskLow |
| 113 | } |
| 114 | l.mu.Lock() |
| 115 | receipts := append([]Receipt(nil), l.receipts...) |
| 116 | l.mu.Unlock() |
| 117 | return ClassifyMutationRisk(receipts, after) |
| 118 | } |
| 119 | |
| 120 | // PathsSince returns distinct paths from successful mutation/write receipts at |
| 121 | // or after the given index (inclusive of the mutation itself when after >= 0). |
| 122 | func (l *Ledger) PathsSince(after int) []string { |
| 123 | if l == nil { |
| 124 | return nil |
| 125 | } |
| 126 | start := after |
| 127 | if start < 0 { |
| 128 | start = 0 |
| 129 | } |
| 130 | l.mu.Lock() |
| 131 | defer l.mu.Unlock() |
| 132 | seen := map[string]bool{} |
| 133 | var out []string |
| 134 | for i := start; i < len(l.receipts); i++ { |
| 135 | r := l.receipts[i] |
| 136 | if !r.Success || (!r.Mutation && !r.Write) { |
| 137 | continue |
| 138 | } |
| 139 | for _, p := range r.Paths { |
| 140 | if p == "" || seen[p] { |
| 141 | continue |
| 142 | } |
| 143 | seen[p] = true |
| 144 | out = append(out, p) |
| 145 | } |
| 146 | } |
| 147 | return out |
| 148 | } |
| 149 | |
| 150 | func pathLooksHighRisk(path string) bool { |
| 151 | lower := strings.ToLower(filepath.ToSlash(path)) |
| 152 | base := strings.ToLower(filepath.Base(path)) |
| 153 | for _, hint := range highRiskPathHints { |
| 154 | if strings.Contains(lower, hint) || strings.Contains(base, hint) { |
| 155 | return true |
| 156 | } |
| 157 | } |
| 158 | return false |
| 159 | } |
| 160 | |
| 161 | func pathLooksLowRisk(path string) bool { |
| 162 | lower := strings.ToLower(filepath.ToSlash(path)) |
| 163 | base := filepath.Base(lower) |
| 164 | if strings.HasSuffix(lower, "_test.go") || strings.HasSuffix(lower, "_test.ts") || |
| 165 | strings.HasSuffix(lower, ".test.ts") || strings.HasSuffix(lower, ".test.tsx") || |
| 166 | strings.HasSuffix(lower, "_spec.ts") || strings.HasSuffix(lower, ".spec.ts") { |
| 167 | return true |
| 168 | } |
| 169 | if strings.Contains(lower, "/testdata/") || strings.Contains(lower, "/__tests__/") || |
| 170 | strings.Contains(lower, "/fixtures/") { |
| 171 | return true |
| 172 | } |
| 173 | switch { |
| 174 | case strings.HasSuffix(base, ".md"), strings.HasSuffix(base, ".mdx"), |
| 175 | strings.HasSuffix(base, ".txt"), strings.HasSuffix(base, ".rst"): |
| 176 | return true |
| 177 | case strings.Contains(lower, "/docs/"), strings.Contains(lower, "/locales/"), |
| 178 | strings.Contains(lower, "/i18n/"), strings.HasPrefix(base, "readme"): |
| 179 | return true |
| 180 | case strings.HasSuffix(base, ".css") && !strings.Contains(lower, "sandbox"): |
| 181 | // Pure presentation styles are low risk unless mixed with other paths. |
| 182 | return true |
| 183 | } |
| 184 | return false |
| 185 | } |
| 186 | |
| 187 | func toolLooksHighRisk(name string) bool { |
| 188 | lower := strings.ToLower(strings.TrimSpace(name)) |
| 189 | for _, hint := range highRiskToolHints { |
| 190 | if strings.Contains(lower, hint) { |
| 191 | return true |
| 192 | } |
| 193 | } |
| 194 | return false |
| 195 | } |
| 196 |