| 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 |
|
} |