~bigbes/sr-ht-spec

ref: 3d811988f9960cf9057e09f30b59ceb71a7623c2 sr-ht-spec/core/policy.go -rw-r--r-- 7.7 KiB
3d811988 — Eugene Blikh service: wrap the merge and reject failures with culpa 10 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
package core

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"strings"
	"unicode/utf8"

	"gopkg.in/yaml.v3"
)

// Policy is a space's `.spec.yml`, versioned in the space itself so that policy
// changes are reviewable like any other change.
//
//	review:
//	  auto_merge: [notes/**, reports/**]
//	schema:
//	  required: [id, title, status]
//	  status: [draft, review, superseded]
type Policy struct {
	Review ReviewPolicy `yaml:"review"`
	Schema Schema       `yaml:"schema"`
}

// ReviewPolicy carries the one real review knob. Single-user means the owner is
// the only approver, so there is no approver list and no approval count — only
// which paths skip the gate.
type ReviewPolicy struct {
	// AutoMerge lists path patterns whose proposals land immediately. This is
	// what implements the bimodal cadence: `specs/` waits for a human, `notes/`
	// and `reports/` flow through.
	AutoMerge []string `yaml:"auto_merge"`
}

// DefaultPolicy is what a space with no `.spec.yml` gets: the house frontmatter
// contract and nothing auto-merged. Defaulting auto_merge to empty is the
// fail-closed direction — a space that has not said anything about review must
// not be quietly laundering unreviewed agent output onto the approved branch.
func DefaultPolicy() Policy {
	return Policy{Schema: DefaultSchema()}
}

// ParsePolicy parses and validates a `.spec.yml`.
//
// Unknown keys are rejected: a `auto-merge:` typo would otherwise parse fine
// and silently do nothing, and a policy file that lies about what it enforces
// is worse than one that fails to load.
//
// A section the file omits falls back to DefaultPolicy. An omitted `schema:`
// means "the house contract", not "a space where no status is allowed and
// therefore no document can ever validate". An explicitly empty list
// (`required: []`) is honoured as written — YAML distinguishes it from an
// absent key by nil-ness, and a space that deliberately turns off required-key
// checking must be able to say so.
func ParsePolicy(data []byte) (Policy, error) {
	var p Policy
	dec := yaml.NewDecoder(bytes.NewReader(data))
	dec.KnownFields(true)
	if err := dec.Decode(&p); err != nil {
		// An empty or comment-only file decodes to io.EOF. That is a valid way
		// to say "defaults", unlike a file that fails to parse.
		if !errors.Is(err, io.EOF) {
			return Policy{}, fmt.Errorf("%w: %v", ErrInvalidPolicy, err)
		}
		return DefaultPolicy(), nil
	}
	// A second YAML document would be silently ignored, so a policy split over
	// two '---' blocks would enforce only half of what it says.
	if err := dec.Decode(new(Policy)); !errors.Is(err, io.EOF) {
		return Policy{}, fmt.Errorf("%w: %s must contain exactly one YAML document", ErrInvalidPolicy, PolicyFile)
	}

	def := DefaultPolicy()
	if p.Schema.Required == nil {
		p.Schema.Required = def.Schema.Required
	}
	if p.Schema.Status == nil {
		p.Schema.Status = def.Schema.Status
	}

	if err := p.Validate(); err != nil {
		return Policy{}, err
	}
	return p, nil
}

// Validate reports whether the policy is internally usable: every auto_merge
// pattern well-formed, and the schema itself well-formed.
func (p Policy) Validate() error {
	for _, pat := range p.Review.AutoMerge {
		if err := ValidatePattern(pat); err != nil {
			return fmt.Errorf("%w: review.auto_merge: %v", ErrInvalidPolicy, err)
		}
	}
	return p.Schema.Validate()
}

// AutoMerges reports whether path may skip human review under this policy.
//
// Fail-closed: a path that does not match, or that is not a valid path at all,
// means "needs a human". The cost of a wrong false is one click; the cost of a
// wrong true is unreviewed agent output landing on the approved branch, which
// is the exact failure this service exists to prevent.
func (p Policy) AutoMerges(path string) bool {
	if err := ValidatePath(path); err != nil {
		return false
	}
	for _, pat := range p.Review.AutoMerge {
		if MatchPattern(pat, path) {
			return true
		}
	}
	return false
}

// ValidatePattern reports whether pat is a well-formed auto_merge pattern.
// The grammar is the familiar globstar subset, and nothing more:
//
//	**   as a whole component, matches zero or more path components
//	*    matches any run of characters within one component, never '/'
//	?    matches exactly one character within one component
//
// Bracket expressions are deliberately absent, so '[' is a literal. Structural
// rules mirror ValidatePath (relative, no empty or traversal components), plus
// one of its own: "**" must be an entire component, because "a**b" has no
// obvious meaning and guessing one is how a policy comes to mean something its
// author did not intend.
func ValidatePattern(pat string) error {
	if pat == "" {
		return fmt.Errorf("%w: empty pattern", ErrInvalidPattern)
	}
	if len(pat) > MaxPathLen {
		return fmt.Errorf("%w: pattern is too long (%d > %d)", ErrInvalidPattern, len(pat), MaxPathLen)
	}
	if !utf8.ValidString(pat) {
		return fmt.Errorf("%w: pattern is not valid UTF-8", ErrInvalidPattern)
	}
	for _, r := range pat {
		if badPathRune(r) {
			return fmt.Errorf("%w: pattern %q contains disallowed rune %U", ErrInvalidPattern, pat, r)
		}
	}
	if strings.HasPrefix(pat, "/") {
		return fmt.Errorf("%w: pattern %q must be relative", ErrInvalidPattern, pat)
	}
	if strings.HasSuffix(pat, "/") {
		return fmt.Errorf("%w: pattern %q must not end in '/' (write %q to match a subtree)",
			ErrInvalidPattern, pat, pat+"**")
	}
	if strings.Contains(pat, `\`) {
		return fmt.Errorf("%w: pattern %q must not contain a backslash", ErrInvalidPattern, pat)
	}
	for _, comp := range strings.Split(pat, "/") {
		switch comp {
		case "":
			return fmt.Errorf("%w: pattern %q has an empty component", ErrInvalidPattern, pat)
		case ".", "..":
			return fmt.Errorf("%w: pattern %q has a traversal component %q", ErrInvalidPattern, pat, comp)
		case "**":
			continue
		}
		if strings.Contains(comp, "**") {
			return fmt.Errorf("%w: pattern %q: %q must be a whole path component", ErrInvalidPattern, pat, "**")
		}
	}
	return nil
}

// MatchPattern reports whether path matches pat under the grammar documented on
// ValidatePattern. An invalid pattern matches nothing; Policy.Validate rejects
// those up front, so a live policy never contains one.
//
// Note that "**" matching zero components means "notes/**" also matches the
// bare path "notes". That is the price of "**/*.md" matching a document at the
// space root, which is the case that actually comes up.
func MatchPattern(pat, path string) bool {
	if ValidatePattern(pat) != nil {
		return false
	}
	return matchComponents(strings.Split(pat, "/"), strings.Split(path, "/"))
}

func matchComponents(pats, segs []string) bool {
	for len(pats) > 0 {
		if pats[0] == "**" {
			if len(pats) == 1 {
				return true
			}
			for i := 0; i <= len(segs); i++ {
				if matchComponents(pats[1:], segs[i:]) {
					return true
				}
			}
			return false
		}
		if len(segs) == 0 {
			return false
		}
		if !matchSegment(pats[0], segs[0]) {
			return false
		}
		pats, segs = pats[1:], segs[1:]
	}
	return len(segs) == 0
}

// matchSegment matches one path component against one pattern component using
// '*' and '?'. Iterative with a single backtrack point, so a pathological
// pattern such as "*a*a*a*a*" stays linear-ish instead of exponential — these
// patterns come from a config file, but the config file is versioned content
// that an agent can propose.
func matchSegment(pat, s string) bool {
	p := []rune(pat)
	t := []rune(s)
	var pi, ti, resume int
	star := -1
	for ti < len(t) {
		switch {
		case pi < len(p) && (p[pi] == '?' || p[pi] == t[ti]):
			pi++
			ti++
		case pi < len(p) && p[pi] == '*':
			star = pi
			resume = ti
			pi++
		case star >= 0:
			resume++
			pi = star + 1
			ti = resume
		default:
			return false
		}
	}
	for pi < len(p) && p[pi] == '*' {
		pi++
	}
	return pi == len(p)
}