| 1 | package evidence |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // ReviewKind distinguishes ordinary review from security review reports. |
| 11 | type ReviewKind string |
| 12 | |
| 13 | const ( |
| 14 | ReviewKindReview ReviewKind = "review" |
| 15 | ReviewKindSecurity ReviewKind = "security" |
| 16 | ) |
| 17 | |
| 18 | // ReviewVerdict is the structured outcome of a review sub-agent. |
| 19 | type ReviewVerdict string |
| 20 | |
| 21 | const ( |
| 22 | ReviewVerdictPass ReviewVerdict = "pass" |
| 23 | ReviewVerdictWarn ReviewVerdict = "warn" |
| 24 | ReviewVerdictBlock ReviewVerdict = "block" |
| 25 | ) |
| 26 | |
| 27 | // ReviewFinding is one structured finding inside a review_report. |
| 28 | type ReviewFinding struct { |
| 29 | Severity string `json:"severity"` |
| 30 | Summary string `json:"summary"` |
| 31 | Path string `json:"path,omitempty"` |
| 32 | Line int `json:"line,omitempty"` |
| 33 | } |
| 34 | |
| 35 | // ReviewReport is the structured payload submitted via the review_report tool. |
| 36 | type ReviewReport struct { |
| 37 | Kind ReviewKind `json:"kind"` |
| 38 | Verdict ReviewVerdict `json:"verdict"` |
| 39 | ReviewedPaths []string `json:"reviewed_paths"` |
| 40 | Findings []ReviewFinding `json:"findings"` |
| 41 | } |
| 42 | |
| 43 | // ParseReviewReport validates and normalizes a review_report argument object. |
| 44 | func ParseReviewReport(raw json.RawMessage) (ReviewReport, error) { |
| 45 | var r ReviewReport |
| 46 | if err := json.Unmarshal(raw, &r); err != nil { |
| 47 | return ReviewReport{}, fmt.Errorf("invalid review_report JSON: %w", err) |
| 48 | } |
| 49 | r.Kind = ReviewKind(strings.ToLower(strings.TrimSpace(string(r.Kind)))) |
| 50 | r.Verdict = ReviewVerdict(strings.ToLower(strings.TrimSpace(string(r.Verdict)))) |
| 51 | switch r.Kind { |
| 52 | case ReviewKindReview, ReviewKindSecurity: |
| 53 | default: |
| 54 | return ReviewReport{}, fmt.Errorf("review_report.kind must be review or security") |
| 55 | } |
| 56 | switch r.Verdict { |
| 57 | case ReviewVerdictPass, ReviewVerdictWarn, ReviewVerdictBlock: |
| 58 | default: |
| 59 | return ReviewReport{}, fmt.Errorf("review_report.verdict must be pass, warn, or block") |
| 60 | } |
| 61 | r.ReviewedPaths = normalizePaths(r.ReviewedPaths) |
| 62 | if len(r.ReviewedPaths) == 0 { |
| 63 | return ReviewReport{}, fmt.Errorf("review_report.reviewed_paths must be non-empty") |
| 64 | } |
| 65 | clean := make([]ReviewFinding, 0, len(r.Findings)) |
| 66 | for _, f := range r.Findings { |
| 67 | f.Severity = strings.TrimSpace(f.Severity) |
| 68 | f.Summary = strings.TrimSpace(f.Summary) |
| 69 | f.Path = strings.TrimSpace(f.Path) |
| 70 | if f.Summary == "" { |
| 71 | return ReviewReport{}, fmt.Errorf("review_report.findings require a non-empty summary") |
| 72 | } |
| 73 | if f.Severity == "" { |
| 74 | f.Severity = "info" |
| 75 | } |
| 76 | clean = append(clean, f) |
| 77 | } |
| 78 | r.Findings = clean |
| 79 | return r, nil |
| 80 | } |
| 81 | |
| 82 | // CoversPaths reports whether every required production path was reviewed. |
| 83 | func (r ReviewReport) CoversPaths(required []string) bool { |
| 84 | if len(required) == 0 { |
| 85 | return len(r.ReviewedPaths) > 0 |
| 86 | } |
| 87 | have := pathSet(normalizePaths(r.ReviewedPaths)) |
| 88 | for _, p := range normalizePaths(required) { |
| 89 | if p == "" { |
| 90 | continue |
| 91 | } |
| 92 | if !have[p] { |
| 93 | // Also accept basename coverage for short relative refs. |
| 94 | found := false |
| 95 | base := filepathBase(p) |
| 96 | for h := range have { |
| 97 | if h == p || filepathBase(h) == base { |
| 98 | found = true |
| 99 | break |
| 100 | } |
| 101 | } |
| 102 | if !found { |
| 103 | return false |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | return true |
| 108 | } |
| 109 | |
| 110 | // HasBlockingFinding reports whether the verdict forbids delivery. |
| 111 | func (r ReviewReport) HasBlockingFinding() bool { |
| 112 | if r.Verdict == ReviewVerdictBlock { |
| 113 | return true |
| 114 | } |
| 115 | for _, f := range r.Findings { |
| 116 | switch strings.ToLower(f.Severity) { |
| 117 | case "block", "blocking", "critical", "error": |
| 118 | return true |
| 119 | } |
| 120 | } |
| 121 | return false |
| 122 | } |
| 123 | |
| 124 | // WarningSummaries returns human-readable warn-level findings for the final summary. |
| 125 | func (r ReviewReport) WarningSummaries() []string { |
| 126 | var out []string |
| 127 | if r.Verdict == ReviewVerdictWarn { |
| 128 | out = append(out, "review verdict=warn") |
| 129 | } |
| 130 | for _, f := range r.Findings { |
| 131 | switch strings.ToLower(f.Severity) { |
| 132 | case "warn", "warning", "medium": |
| 133 | msg := f.Summary |
| 134 | if f.Path != "" { |
| 135 | msg = f.Path + ": " + msg |
| 136 | } |
| 137 | out = append(out, msg) |
| 138 | } |
| 139 | } |
| 140 | return out |
| 141 | } |
| 142 | |
| 143 | func filepathBase(p string) string { |
| 144 | p = strings.ReplaceAll(p, `\`, `/`) |
| 145 | if i := strings.LastIndex(p, "/"); i >= 0 { |
| 146 | return p[i+1:] |
| 147 | } |
| 148 | return p |
| 149 | } |
| 150 | |
| 151 | // ReviewReportReceipt is stored on the ledger when a review_report succeeds. |
| 152 | type ReviewReportReceipt struct { |
| 153 | Report ReviewReport |
| 154 | After int // mutation index this report claims to cover; -1 if unknown |
| 155 | } |
| 156 | |
| 157 | // HasStructuredReviewAfter reports whether a successful structured review of |
| 158 | // the given kind was recorded after the mutation, covering required paths, and |
| 159 | // without a blocking verdict. |
| 160 | func (l *Ledger) HasStructuredReviewAfter(kind ReviewKind, after int, requiredPaths []string) (ok bool, blocking bool, report *ReviewReport) { |
| 161 | if l == nil { |
| 162 | return false, false, nil |
| 163 | } |
| 164 | start := after + 1 |
| 165 | if start < 0 { |
| 166 | start = 0 |
| 167 | } |
| 168 | l.mu.Lock() |
| 169 | defer l.mu.Unlock() |
| 170 | for i := start; i < len(l.receipts); i++ { |
| 171 | r := l.receipts[i] |
| 172 | if !r.Success || r.ToolName != "review_report" { |
| 173 | continue |
| 174 | } |
| 175 | parsed, err := ParseReviewReport(r.Args) |
| 176 | if err != nil { |
| 177 | continue |
| 178 | } |
| 179 | if parsed.Kind != kind { |
| 180 | continue |
| 181 | } |
| 182 | if !parsed.CoversPaths(requiredPaths) { |
| 183 | continue |
| 184 | } |
| 185 | if parsed.HasBlockingFinding() { |
| 186 | return true, true, &parsed |
| 187 | } |
| 188 | return true, false, &parsed |
| 189 | } |
| 190 | return false, false, nil |
| 191 | } |
| 192 | |
| 193 | // HasSuccessfulStructuredReviewAfter is a convenience for non-blocking coverage. |
| 194 | func (l *Ledger) HasSuccessfulStructuredReviewAfter(kind ReviewKind, after int, requiredPaths []string) bool { |
| 195 | ok, blocking, _ := l.HasStructuredReviewAfter(kind, after, requiredPaths) |
| 196 | return ok && !blocking |
| 197 | } |
| 198 | |
| 199 | // HasSuccessfulReviewReportOfKind reports whether any successful review_report |
| 200 | // receipt of the given kind exists, regardless of mutation ordering or path |
| 201 | // coverage. Subagent completion gates use it: a review subagent that never |
| 202 | // submitted a typed report must fail its parent tool call instead of returning |
| 203 | // prose the delivery gate cannot verify. |
| 204 | func (l *Ledger) HasSuccessfulReviewReportOfKind(kind ReviewKind) bool { |
| 205 | if l == nil { |
| 206 | return false |
| 207 | } |
| 208 | l.mu.Lock() |
| 209 | defer l.mu.Unlock() |
| 210 | for _, r := range l.receipts { |
| 211 | if !r.Success || r.ToolName != "review_report" { |
| 212 | continue |
| 213 | } |
| 214 | parsed, err := ParseReviewReport(r.Args) |
| 215 | if err != nil { |
| 216 | continue |
| 217 | } |
| 218 | if parsed.Kind == kind { |
| 219 | return true |
| 220 | } |
| 221 | } |
| 222 | return false |
| 223 | } |
| 224 | |
| 225 | // HasReadEvidenceForPath reports whether the host observed the CONTENT of |
| 226 | // path: a successful read receipt whose extracted paths equal the claimed |
| 227 | // path after normalization (or contain it as a slash-suffix of a fuller |
| 228 | // observed path), or a content-revealing bash command (diff/cmp/cat/head/ |
| 229 | // tail, git diff/show) that names the path in its parsed argv AND produced |
| 230 | // non-empty host-observed output. Deliberately rejected: write receipts |
| 231 | // (writing is not reviewing), arbitrary path-mentioning commands like git |
| 232 | // status or echo, pipelines and redirects (they transform or swallow the |
| 233 | // content), summary flags (--stat, --name-only, -q), zero-output runs |
| 234 | // (head -n 0, >/dev/null), substring path hits (path.bak), and reverse |
| 235 | // basename suffix matching (a bare "agent.go" receipt must not satisfy a |
| 236 | // claim for a specific full path). |
| 237 | func (l *Ledger) HasReadEvidenceForPath(path string) bool { |
| 238 | p := normalizePath(path) |
| 239 | if l == nil || p == "" { |
| 240 | return false |
| 241 | } |
| 242 | needle := strings.ToLower(filepath.ToSlash(p)) |
| 243 | l.mu.Lock() |
| 244 | defer l.mu.Unlock() |
| 245 | for _, r := range l.receipts { |
| 246 | if !r.Success { |
| 247 | continue |
| 248 | } |
| 249 | if r.Read { |
| 250 | for _, rp := range r.Paths { |
| 251 | o := strings.ToLower(filepath.ToSlash(normalizePath(rp))) |
| 252 | if o == needle || strings.HasSuffix(o, "/"+needle) { |
| 253 | return true |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | if r.ToolName == "bash" && r.OutputBytes > 0 && commandShowsContentForPath(r.Command, needle) { |
| 258 | return true |
| 259 | } |
| 260 | } |
| 261 | return false |
| 262 | } |
| 263 |