Files
2026-07-13 13:00:08 +08:00

196 lines
4.9 KiB
Go

package evidence
import (
"path/filepath"
"strings"
)
// RiskLevel classifies the latest post-mutation change set for adaptive review.
type RiskLevel string
const (
RiskLow RiskLevel = "low"
RiskMedium RiskLevel = "medium"
RiskHigh RiskLevel = "high"
)
// highRiskPathHints elevate ordinary production edits to High when the path
// touches auth, crypto, networking, providers, plugins, sandbox, config,
// migrations, persistence, or concurrency.
var highRiskPathHints = []string{
"auth", "permission", "secret", "credential", "token", "password", "oauth",
"crypto", "encrypt", "decrypt", "tls", "ssl", "keyring",
"network", "proxy", "http", "websocket", "provider",
"plugin", "mcp", "tool", "schema", "sandbox",
"config", "migrate", "migration", "persist", "store", "database", "db",
"concurrent", "mutex", "race", "lock", "atomic",
}
// highRiskToolHints elevate opaque or privileged mutation surfaces.
var highRiskToolHints = []string{
"mcp__", "install_source", "install_skill", "plugin",
}
// ClassifyMutationRisk scores the change set after the latest mutation.
// Low: docs/tests/i18n/pure presentation only, with no opaque writes.
// Medium: ordinary production code or limited multi-file edits.
// High: security-sensitive surfaces, opaque mutations, or 10+ paths.
func ClassifyMutationRisk(receipts []Receipt, after int) RiskLevel {
start := after + 1
if start < 0 {
start = 0
}
var paths []string
seen := map[string]bool{}
opaque := false
hasProd := false
onlyLow := true
// Include the mutation receipt itself.
if after >= 0 && after < len(receipts) {
r := receipts[after]
if r.Success && r.Mutation {
if len(r.Paths) == 0 {
opaque = true
}
for _, p := range r.Paths {
if !seen[p] {
seen[p] = true
paths = append(paths, p)
}
}
if toolLooksHighRisk(r.ToolName) {
return RiskHigh
}
}
}
for i := start; i < len(receipts); i++ {
r := receipts[i]
if !r.Success || !r.Mutation {
continue
}
if len(r.Paths) == 0 {
opaque = true
}
if toolLooksHighRisk(r.ToolName) {
return RiskHigh
}
for _, p := range r.Paths {
if !seen[p] {
seen[p] = true
paths = append(paths, p)
}
}
}
if opaque {
return RiskHigh
}
if len(paths) == 0 {
return RiskLow
}
if len(paths) >= 10 {
return RiskHigh
}
for _, p := range paths {
if pathLooksHighRisk(p) {
return RiskHigh
}
if !pathLooksLowRisk(p) {
onlyLow = false
hasProd = true
}
}
if onlyLow && !hasProd {
return RiskLow
}
return RiskMedium
}
// MutationRiskAfter classifies risk from the ledger using the latest mutation.
func (l *Ledger) MutationRiskAfter(after int) RiskLevel {
if l == nil {
return RiskLow
}
l.mu.Lock()
receipts := append([]Receipt(nil), l.receipts...)
l.mu.Unlock()
return ClassifyMutationRisk(receipts, after)
}
// PathsSince returns distinct paths from successful mutation/write receipts at
// or after the given index (inclusive of the mutation itself when after >= 0).
func (l *Ledger) PathsSince(after int) []string {
if l == nil {
return nil
}
start := after
if start < 0 {
start = 0
}
l.mu.Lock()
defer l.mu.Unlock()
seen := map[string]bool{}
var out []string
for i := start; i < len(l.receipts); i++ {
r := l.receipts[i]
if !r.Success || (!r.Mutation && !r.Write) {
continue
}
for _, p := range r.Paths {
if p == "" || seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
}
return out
}
func pathLooksHighRisk(path string) bool {
lower := strings.ToLower(filepath.ToSlash(path))
base := strings.ToLower(filepath.Base(path))
for _, hint := range highRiskPathHints {
if strings.Contains(lower, hint) || strings.Contains(base, hint) {
return true
}
}
return false
}
func pathLooksLowRisk(path string) bool {
lower := strings.ToLower(filepath.ToSlash(path))
base := filepath.Base(lower)
if strings.HasSuffix(lower, "_test.go") || strings.HasSuffix(lower, "_test.ts") ||
strings.HasSuffix(lower, ".test.ts") || strings.HasSuffix(lower, ".test.tsx") ||
strings.HasSuffix(lower, "_spec.ts") || strings.HasSuffix(lower, ".spec.ts") {
return true
}
if strings.Contains(lower, "/testdata/") || strings.Contains(lower, "/__tests__/") ||
strings.Contains(lower, "/fixtures/") {
return true
}
switch {
case strings.HasSuffix(base, ".md"), strings.HasSuffix(base, ".mdx"),
strings.HasSuffix(base, ".txt"), strings.HasSuffix(base, ".rst"):
return true
case strings.Contains(lower, "/docs/"), strings.Contains(lower, "/locales/"),
strings.Contains(lower, "/i18n/"), strings.HasPrefix(base, "readme"):
return true
case strings.HasSuffix(base, ".css") && !strings.Contains(lower, "sandbox"):
// Pure presentation styles are low risk unless mixed with other paths.
return true
}
return false
}
func toolLooksHighRisk(name string) bool {
lower := strings.ToLower(strings.TrimSpace(name))
for _, hint := range highRiskToolHints {
if strings.Contains(lower, hint) {
return true
}
}
return false
}