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