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