| 1 | package memory |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "regexp" |
| 6 | "strings" |
| 7 | "unicode" |
| 8 | |
| 9 | "reasonix/internal/secrets" |
| 10 | ) |
| 11 | |
| 12 | const maxAutoRememberBodyRunes = 6000 |
| 13 | |
| 14 | var rememberEmailPattern = regexp.MustCompile(`(?i)\b[a-z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+\b`) |
| 15 | |
| 16 | // RememberAssessment explains whether an interactive host may safely allow a |
| 17 | // remember call without a confirmation dialog. |
| 18 | type RememberAssessment struct { |
| 19 | AutoAllow bool |
| 20 | Reason string |
| 21 | Name string |
| 22 | Type Type |
| 23 | Scope FactScope |
| 24 | } |
| 25 | |
| 26 | // AssessRememberWrite permits only bounded, non-sensitive project/reference |
| 27 | // creates. Global facts, preferences, feedback, updates, and potential |
| 28 | // duplicates remain explicit user decisions. |
| 29 | func AssessRememberWrite(store Store, args json.RawMessage) RememberAssessment { |
| 30 | in, err := parseRememberRequest(args) |
| 31 | if err != nil { |
| 32 | return RememberAssessment{Reason: "invalid remember request"} |
| 33 | } |
| 34 | ref := parseMemoryReference(rememberRequestName(in)) |
| 35 | assessment := RememberAssessment{ |
| 36 | Name: ref.name, |
| 37 | Type: NormalizeType(in.Type), |
| 38 | Scope: NormalizeFactScope(in.Scope), |
| 39 | } |
| 40 | if ref.qualified { |
| 41 | if strings.TrimSpace(in.Scope) != "" && assessment.Scope != ref.scope { |
| 42 | assessment.Reason = "memory reference scope conflicts with explicit scope" |
| 43 | return assessment |
| 44 | } |
| 45 | assessment.Scope = ref.scope |
| 46 | } |
| 47 | if strings.TrimSpace(in.Description) == "" || strings.TrimSpace(in.Body) == "" { |
| 48 | assessment.Reason = "description and body are required" |
| 49 | return assessment |
| 50 | } |
| 51 | if store.Dir == "" { |
| 52 | assessment.Reason = "project memory store is unavailable" |
| 53 | return assessment |
| 54 | } |
| 55 | typ := strings.ToLower(strings.TrimSpace(in.Type)) |
| 56 | if typ != string(TypeProject) && typ != string(TypeReference) { |
| 57 | assessment.Reason = "only explicitly classified project/reference facts are low-risk" |
| 58 | return assessment |
| 59 | } |
| 60 | if assessment.Scope != FactScopeProject { |
| 61 | assessment.Reason = "global memory requires confirmation" |
| 62 | return assessment |
| 63 | } |
| 64 | if strings.TrimSpace(in.ID) != "" || in.ExpectedRevision > 0 { |
| 65 | assessment.Reason = "memory updates require confirmation" |
| 66 | return assessment |
| 67 | } |
| 68 | if assessment.Name == "" { |
| 69 | assessment.Reason = "memory name cannot be derived" |
| 70 | return assessment |
| 71 | } |
| 72 | if len([]rune(in.Body)) > maxAutoRememberBodyRunes { |
| 73 | assessment.Reason = "memory body exceeds the automatic-write budget" |
| 74 | return assessment |
| 75 | } |
| 76 | if rememberRequestSensitive(in) { |
| 77 | assessment.Reason = "memory may contain sensitive information" |
| 78 | return assessment |
| 79 | } |
| 80 | if rememberRequestOverlaps(store, in, assessment.Name) { |
| 81 | assessment.Reason = "an existing memory may already cover this fact" |
| 82 | return assessment |
| 83 | } |
| 84 | assessment.AutoAllow = true |
| 85 | assessment.Reason = "new low-risk project fact" |
| 86 | return assessment |
| 87 | } |
| 88 | |
| 89 | func rememberRequestSensitive(in rememberRequest) bool { |
| 90 | text := strings.Join([]string{in.Name, in.Title, in.Description, in.Body}, "\n") |
| 91 | if secrets.Redact(text) != text || rememberEmailPattern.MatchString(text) { |
| 92 | return true |
| 93 | } |
| 94 | upper := strings.ToUpper(text) |
| 95 | return strings.Contains(upper, "BEGIN PRIVATE KEY") || strings.Contains(upper, "BEGIN OPENSSH PRIVATE KEY") |
| 96 | } |
| 97 | |
| 98 | func rememberRequestOverlaps(store Store, in rememberRequest, name string) bool { |
| 99 | wantTitle := normalizedMemoryPhrase(in.Title) |
| 100 | wantDescription := normalizedMemoryPhrase(in.Description) |
| 101 | for _, existing := range store.ListAll() { |
| 102 | if slug(existing.Name) == name { |
| 103 | return true |
| 104 | } |
| 105 | if wantTitle != "" && normalizedMemoryPhrase(existing.Title) == wantTitle { |
| 106 | return true |
| 107 | } |
| 108 | if wantDescription != "" && normalizedMemoryPhrase(existing.Description) == wantDescription { |
| 109 | return true |
| 110 | } |
| 111 | } |
| 112 | return false |
| 113 | } |
| 114 | |
| 115 | func normalizedMemoryPhrase(value string) string { |
| 116 | return strings.Map(func(r rune) rune { |
| 117 | if unicode.IsLetter(r) || unicode.IsDigit(r) { |
| 118 | return unicode.ToLower(r) |
| 119 | } |
| 120 | return -1 |
| 121 | }, value) |
| 122 | } |
| 123 |