coverage~bigbes/sr-ht-spec3cb1c03dcore/policy.go

Coverage
98.8% 85/86 statements
Δ
Blob
18c1a19
Uncovered L237-L238
1 package core
2
3 import (
4 "bytes"
5 "errors"
6 "fmt"
7 "io"
8 "strings"
9 "unicode/utf8"
10
11 "gopkg.in/yaml.v3"
12 )
13
14 // Policy is a space's `.spec.yml`, versioned in the space itself so that policy
15 // changes are reviewable like any other change.
16 //
17 // review:
18 // auto_merge: [notes/**, reports/**]
19 // schema:
20 // required: [id, title, status]
21 // status: [draft, review, superseded]
22 type Policy struct {
23 Review ReviewPolicy `yaml:"review"`
24 Schema Schema `yaml:"schema"`
25 }
26
27 // ReviewPolicy carries the one real review knob. Single-user means the owner is
28 // the only approver, so there is no approver list and no approval count — only
29 // which paths skip the gate.
30 type ReviewPolicy struct {
31 // AutoMerge lists path patterns whose proposals land immediately. This is
32 // what implements the bimodal cadence: `specs/` waits for a human, `notes/`
33 // and `reports/` flow through.
34 AutoMerge []string `yaml:"auto_merge"`
35 }
36
37 // DefaultPolicy is what a space with no `.spec.yml` gets: the house frontmatter
38 // contract and nothing auto-merged. Defaulting auto_merge to empty is the
39 // fail-closed direction — a space that has not said anything about review must
40 // not be quietly laundering unreviewed agent output onto the approved branch.
41 15 func DefaultPolicy() Policy {
42 15 return Policy{Schema: DefaultSchema()}
43 15 }
44
45 // ParsePolicy parses and validates a `.spec.yml`.
46 //
47 // Unknown keys are rejected: a `auto-merge:` typo would otherwise parse fine
48 // and silently do nothing, and a policy file that lies about what it enforces
49 // is worse than one that fails to load.
50 //
51 // A section the file omits falls back to DefaultPolicy. An omitted `schema:`
52 // means "the house contract", not "a space where no status is allowed and
53 // therefore no document can ever validate". An explicitly empty list
54 // (`required: []`) is honoured as written — YAML distinguishes it from an
55 // absent key by nil-ness, and a space that deliberately turns off required-key
56 // checking must be able to say so.
57 23 func ParsePolicy(data []byte) (Policy, error) {
58 23 var p Policy
59 23 dec := yaml.NewDecoder(bytes.NewReader(data))
60 23 dec.KnownFields(true)
61 23 if err := dec.Decode(&p); err != nil {
62 11 // An empty or comment-only file decodes to io.EOF. That is a valid way
63 11 // to say "defaults", unlike a file that fails to parse.
64 11 if !errors.Is(err, io.EOF) {
65 9 return Policy{}, fmt.Errorf("%w: %v", ErrInvalidPolicy, err)
66 9 }
67 2 return DefaultPolicy(), nil
68 }
69 // A second YAML document would be silently ignored, so a policy split over
70 // two '---' blocks would enforce only half of what it says.
71 12 if err := dec.Decode(new(Policy)); !errors.Is(err, io.EOF) {
72 1 return Policy{}, fmt.Errorf("%w: %s must contain exactly one YAML document", ErrInvalidPolicy, PolicyFile)
73 1 }
74
75 11 def := DefaultPolicy()
76 11 if p.Schema.Required == nil {
77 8 p.Schema.Required = def.Schema.Required
78 8 }
79 11 if p.Schema.Status == nil {
80 7 p.Schema.Status = def.Schema.Status
81 7 }
82
83 11 if err := p.Validate(); err != nil {
84 7 return Policy{}, err
85 7 }
86 4 return p, nil
87 }
88
89 // Validate reports whether the policy is internally usable: every auto_merge
90 // pattern well-formed, and the schema itself well-formed.
91 16 func (p Policy) Validate() error {
92 16 for _, pat := range p.Review.AutoMerge {
93 13 if err := ValidatePattern(pat); err != nil {
94 4 return fmt.Errorf("%w: review.auto_merge: %v", ErrInvalidPolicy, err)
95 4 }
96 }
97 12 return p.Schema.Validate()
98 }
99
100 // AutoMerges reports whether path may skip human review under this policy.
101 //
102 // Fail-closed: a path that does not match, or that is not a valid path at all,
103 // means "needs a human". The cost of a wrong false is one click; the cost of a
104 // wrong true is unreviewed agent output landing on the approved branch, which
105 // is the exact failure this service exists to prevent.
106 17 func (p Policy) AutoMerges(path string) bool {
107 17 if err := ValidatePath(path); err != nil {
108 5 return false
109 5 }
110 22 for _, pat := range p.Review.AutoMerge {
111 22 if MatchPattern(pat, path) {
112 4 return true
113 4 }
114 }
115 8 return false
116 }
117
118 // ValidatePattern reports whether pat is a well-formed auto_merge pattern.
119 // The grammar is the familiar globstar subset, and nothing more:
120 //
121 // ** as a whole component, matches zero or more path components
122 // * matches any run of characters within one component, never '/'
123 // ? matches exactly one character within one component
124 //
125 // Bracket expressions are deliberately absent, so '[' is a literal. Structural
126 // rules mirror ValidatePath (relative, no empty or traversal components), plus
127 // one of its own: "**" must be an entire component, because "a**b" has no
128 // obvious meaning and guessing one is how a policy comes to mean something its
129 // author did not intend.
130 103 func ValidatePattern(pat string) error {
131 103 if pat == "" {
132 2 return fmt.Errorf("%w: empty pattern", ErrInvalidPattern)
133 2 }
134 101 if len(pat) > MaxPathLen {
135 1 return fmt.Errorf("%w: pattern is too long (%d > %d)", ErrInvalidPattern, len(pat), MaxPathLen)
136 1 }
137 100 if !utf8.ValidString(pat) {
138 1 return fmt.Errorf("%w: pattern is not valid UTF-8", ErrInvalidPattern)
139 1 }
140 888 for _, r := range pat {
141 888 if badPathRune(r) {
142 3 return fmt.Errorf("%w: pattern %q contains disallowed rune %U", ErrInvalidPattern, pat, r)
143 3 }
144 }
145 96 if strings.HasPrefix(pat, "/") {
146 3 return fmt.Errorf("%w: pattern %q must be relative", ErrInvalidPattern, pat)
147 3 }
148 93 if strings.HasSuffix(pat, "/") {
149 1 return fmt.Errorf("%w: pattern %q must not end in '/' (write %q to match a subtree)",
150 1 ErrInvalidPattern, pat, pat+"**")
151 1 }
152 92 if strings.Contains(pat, `\`) {
153 1 return fmt.Errorf("%w: pattern %q must not contain a backslash", ErrInvalidPattern, pat)
154 1 }
155 169 for _, comp := range strings.Split(pat, "/") {
156 169 switch comp {
157 1 case "":
158 1 return fmt.Errorf("%w: pattern %q has an empty component", ErrInvalidPattern, pat)
159 6 case ".", "..":
160 6 return fmt.Errorf("%w: pattern %q has a traversal component %q", ErrInvalidPattern, pat, comp)
161 45 case "**":
162 45 continue
163 }
164 117 if strings.Contains(comp, "**") {
165 6 return fmt.Errorf("%w: pattern %q: %q must be a whole path component", ErrInvalidPattern, pat, "**")
166 6 }
167 }
168 78 return nil
169 }
170
171 // MatchPattern reports whether path matches pat under the grammar documented on
172 // ValidatePattern. An invalid pattern matches nothing; Policy.Validate rejects
173 // those up front, so a live policy never contains one.
174 //
175 // Note that "**" matching zero components means "notes/**" also matches the
176 // bare path "notes". That is the price of "**/*.md" matching a document at the
177 // space root, which is the case that actually comes up.
178 63 func MatchPattern(pat, path string) bool {
179 63 if ValidatePattern(pat) != nil {
180 4 return false
181 4 }
182 59 return matchComponents(strings.Split(pat, "/"), strings.Split(path, "/"))
183 }
184
185 80 func matchComponents(pats, segs []string) bool {
186 107 for len(pats) > 0 {
187 107 if pats[0] == "**" {
188 17 if len(pats) == 1 {
189 8 return true
190 8 }
191 21 for i := 0; i <= len(segs); i++ {
192 21 if matchComponents(pats[1:], segs[i:]) {
193 7 return true
194 7 }
195 }
196 2 return false
197 }
198 90 if len(segs) == 0 {
199 3 return false
200 3 }
201 87 if !matchSegment(pats[0], segs[0]) {
202 41 return false
203 41 }
204 46 pats, segs = pats[1:], segs[1:]
205 }
206 19 return len(segs) == 0
207 }
208
209 // matchSegment matches one path component against one pattern component using
210 // '*' and '?'. Iterative with a single backtrack point, so a pathological
211 // pattern such as "*a*a*a*a*" stays linear-ish instead of exponential — these
212 // patterns come from a config file, but the config file is versioned content
213 // that an agent can propose.
214 87 func matchSegment(pat, s string) bool {
215 87 p := []rune(pat)
216 87 t := []rune(s)
217 87 var pi, ti, resume int
218 87 star := -1
219 402 for ti < len(t) {
220 402 switch {
221 233 case pi < len(p) && (p[pi] == '?' || p[pi] == t[ti]):
222 233 pi++
223 233 ti++
224 35 case pi < len(p) && p[pi] == '*':
225 35 star = pi
226 35 resume = ti
227 35 pi++
228 106 case star >= 0:
229 106 resume++
230 106 pi = star + 1
231 106 ti = resume
232 28 default:
233 28 return false
234 }
235 }
236 59 for pi < len(p) && p[pi] == '*' {
237 0 pi++
238 0 }
239 59 return pi == len(p)
240 }