internal/policy/policy.go
1
package policy
3
import (
4
"crypto/rand"
5
"encoding/hex"
6
"errors"
7
"fmt"
8
"io"
9
"os"
10
"path"
11
"regexp"
12
"strings"
14
yaml "go.yaml.in/yaml/v3"
16
"github.com/koment-dev/koment/internal/api"
17
)
19
const (
20
// APIVersion is matched exactly, exactly as an annotation record's is.
21
APIVersion = api.Version
23
// KindPolicy is the resource kind of a repository policy.
24
KindPolicy = "Policy"
26
ModeStrict = "strict"
27
FileName = ".koment/policy.yaml"
28
SchemaURL = api.SchemaBase + "policy.schema.json"
29
)
31
// Intrinsic names one class of source comment that may remain inline.
32
type Intrinsic string
34
const (
35
IntrinsicToolchain Intrinsic = "toolchain-directive"
36
IntrinsicGeneratedMarker Intrinsic = "generated-marker"
37
IntrinsicUpstreamLink Intrinsic = "upstream-link"
38
IntrinsicDeprecated Intrinsic = "deprecated"
39
IntrinsicPublicAPI Intrinsic = "public-api"
40
)
42
// Principle names one extra rule the generated agent contract states. A
43
// principle is a claim a reviewer can check, not a preference.
44
type Principle string
46
const (
47
PrincipleBackCompatEvidence Principle = "back-compat-evidence"
48
)
50
var Principles = []Principle{PrincipleBackCompatEvidence}
52
var principleText = map[Principle]string{
53
PrincipleBackCompatEvidence: "A back-compatibility claim needs evidence: a migration path the binary performs, " +
54
"or an ADR naming the version the old shape was cut off at. Without either, the change is breaking " +
55
"and its commit subject says so with `feat!:`.",
56
}
58
// Adapter names one generated agent instruction surface.
59
type Adapter string
61
const (
62
AdapterAgents Adapter = "agents"
63
AdapterClaude Adapter = "claude"
64
AdapterCopilot Adapter = "copilot"
65
AdapterCursor Adapter = "cursor"
66
AdapterCodex Adapter = "codex"
67
AdapterOpencode Adapter = "opencode"
68
)
70
// Policy is the repository enforcement contract, shaped like every other
71
// committed koment resource (ADR 0121).
72
type Policy struct {
73
APIVersion string `yaml:"apiVersion"`
74
Kind string `yaml:"kind"`
75
Spec Spec `yaml:"spec"`
76
}
78
// Spec is everything the repository decided. A policy has no metadata because
79
// it is a singleton with no identity of its own: the file path is the name.
80
type Spec struct {
81
Comments CommentsPolicy `yaml:"comments"`
82
Agents AgentsPolicy `yaml:"agents"`
83
}
85
// CommentsPolicy configures strict classification and repository exclusions.
86
type CommentsPolicy struct {
87
Mode string `yaml:"mode"`
88
Intrinsic []Intrinsic `yaml:"intrinsic"`
89
GeneratedPaths []string `yaml:"generatedPaths,omitempty"`
90
VendoredPaths []string `yaml:"vendoredPaths,omitempty"`
91
}
93
// AgentsPolicy selects generated instruction adapters and the principles they
94
// state.
95
type AgentsPolicy struct {
96
Adapters []Adapter `yaml:"adapters"`
97
Principles []Principle `yaml:"principles,omitempty"`
98
}
100
// Default returns the strict policy installed for a new repository.
101
func Default() Policy {
102
return Policy{
103
APIVersion: APIVersion,
104
Kind: KindPolicy,
105
Spec: Spec{
106
Comments: CommentsPolicy{
107
Mode: ModeStrict,
108
Intrinsic: []Intrinsic{
109
IntrinsicToolchain, IntrinsicGeneratedMarker, IntrinsicUpstreamLink,
110
IntrinsicDeprecated, IntrinsicPublicAPI,
111
},
112
GeneratedPaths: []string{"**/*.gen.go", "**/*.generated.go"},
113
VendoredPaths: []string{"vendor/**"},
114
},
115
Agents: AgentsPolicy{
116
Adapters: []Adapter{
117
AdapterAgents, AdapterClaude, AdapterCopilot, AdapterCursor, AdapterCodex,
118
AdapterOpencode,
119
},
120
Principles: []Principle{PrincipleBackCompatEvidence},
121
},
122
},
123
}
124
}
126
// Load reads and strictly validates the repository policy.
127
func Load(rootPath string) (configured Policy, returnedError error) {
128
root, err := os.OpenRoot(rootPath)
129
if err != nil {
130
return Policy{}, fmt.Errorf("opening repository root %s: %w", rootPath, err)
131
}
132
defer func() {
133
if closeErr := root.Close(); closeErr != nil {
134
returnedError = errors.Join(returnedError, closeErr)
135
}
136
}()
137
content, err := root.ReadFile(FileName)
138
if err != nil {
139
return Policy{}, fmt.Errorf("reading %s: %w", FileName, err)
140
}
141
configured, upgraded, err := decode(content)
142
if err != nil {
143
return Policy{}, err
144
}
145
if err := configured.Validate(); err != nil {
146
return Policy{}, fmt.Errorf("in %s: %w", FileName, err)
147
}
148
if upgraded {
149
if err := writeTo(root, configured); err != nil {
150
return Policy{}, err
151
}
152
}
153
return configured, nil
154
}
156
type policyShape struct {
157
APIVersion string `yaml:"apiVersion"`
158
Version *int `yaml:"version"`
159
}
161
func decode(content []byte) (Policy, bool, error) {
162
var shape policyShape
163
if err := yaml.Unmarshal(content, &shape); err != nil {
164
return Policy{}, false, fmt.Errorf("parsing %s: %w", FileName, err)
165
}
166
switch {
167
case shape.APIVersion == APIVersion:
168
var configured Policy
169
if err := decodeOneDocument(content, &configured); err != nil {
170
return Policy{}, false, err
171
}
172
return configured, false, nil
173
case shape.APIVersion != "":
174
return Policy{}, false, fmt.Errorf(
175
"incompatible %s: apiVersion %q is not supported; this binary reads %s (ADR 0121)",
176
FileName, shape.APIVersion, APIVersion)
177
case shape.Version != nil && *shape.Version == LegacyVersion:
178
var legacy legacyPolicy
179
if err := decodeOneDocument(content, &legacy); err != nil {
180
return Policy{}, false, err
181
}
182
return upgradeLegacy(legacy), true, nil
183
default:
184
return Policy{}, false, fmt.Errorf(
185
"incompatible %s: no apiVersion; a koment policy starts with `apiVersion: %s` (ADR 0121)",
186
FileName, APIVersion)
187
}
188
}
190
func decodeOneDocument(content []byte, into any) error {
191
decoder := yaml.NewDecoder(strings.NewReader(string(content)))
192
decoder.KnownFields(true)
193
if err := decoder.Decode(into); err != nil {
194
return fmt.Errorf("parsing %s: %w", FileName, err)
195
}
196
var trailing any
197
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
198
if err == nil {
199
return fmt.Errorf("parsing %s: multiple YAML documents are not allowed", FileName)
200
}
201
return fmt.Errorf("parsing %s after the policy: %w", FileName, err)
202
}
203
return nil
204
}
206
// Install writes the default policy only when none exists.
207
func Install(rootPath string) (Policy, bool, error) {
208
configured, err := Load(rootPath)
209
if err == nil {
210
return configured, false, nil
211
}
212
if !errors.Is(err, os.ErrNotExist) {
213
return Policy{}, false, err
214
}
215
configured = Default()
216
if err := Save(rootPath, configured); err != nil {
217
return Policy{}, false, err
218
}
219
return configured, true, nil
220
}
222
// Save writes a validated policy atomically beneath the repository root.
223
func Save(rootPath string, configured Policy) (returnedError error) {
224
if err := configured.Validate(); err != nil {
225
return err
226
}
227
root, err := os.OpenRoot(rootPath)
228
if err != nil {
229
return fmt.Errorf("opening repository root %s: %w", rootPath, err)
230
}
231
defer func() {
232
if closeErr := root.Close(); closeErr != nil {
233
returnedError = errors.Join(returnedError, closeErr)
234
}
235
}()
236
return writeTo(root, configured)
237
}
239
func writeTo(root *os.Root, configured Policy) error {
240
encoded, err := encode(configured)
241
if err != nil {
242
return err
243
}
244
if err := root.MkdirAll(path.Dir(FileName), 0o755); err != nil {
245
return fmt.Errorf("creating %s: %w", path.Dir(FileName), err)
246
}
247
return writeAtomically(root, FileName, encoded)
248
}
250
func encode(configured Policy) ([]byte, error) {
251
var encoded strings.Builder
252
encoded.WriteString("# yaml-language-server: $schema=" + SchemaURL + "\n")
253
encoder := yaml.NewEncoder(&encoded)
254
encoder.SetIndent(2)
255
if err := encoder.Encode(configured); err != nil {
256
return nil, fmt.Errorf("encoding %s: %w", FileName, err)
257
}
258
if err := encoder.Close(); err != nil {
259
return nil, fmt.Errorf("encoding %s: %w", FileName, err)
260
}
261
return []byte(encoded.String()), nil
262
}
264
// Validate rejects policy drift and unsupported bypasses.
265
func (p Policy) Validate() error {
266
if p.APIVersion != APIVersion {
267
return fmt.Errorf("apiVersion %q, want %q", p.APIVersion, APIVersion)
268
}
269
if p.Kind != KindPolicy {
270
return fmt.Errorf("kind %q, want %q", p.Kind, KindPolicy)
271
}
272
if p.Spec.Comments.Mode != ModeStrict {
273
return fmt.Errorf("spec.comments.mode %q, want %q", p.Spec.Comments.Mode, ModeStrict)
274
}
275
if err := validateIntrinsics(p.Spec.Comments.Intrinsic); err != nil {
276
return err
277
}
278
if err := validateGlobs("spec.comments.generatedPaths", p.Spec.Comments.GeneratedPaths); err != nil {
279
return err
280
}
281
if err := validateGlobs("spec.comments.vendoredPaths", p.Spec.Comments.VendoredPaths); err != nil {
282
return err
283
}
284
if err := validatePrinciples(p.Spec.Agents.Principles); err != nil {
285
return err
286
}
287
return validateAdapters(p.Spec.Agents.Adapters)
288
}
290
// States returns the wording of every principle this policy enables, in the
291
// order the vocabulary declares them so that a regenerated contract does not
292
// diff against itself.
293
func (p Policy) States() []string {
294
stated := make([]string, 0, len(p.Spec.Agents.Principles))
295
for _, principle := range Principles {
296
for _, enabled := range p.Spec.Agents.Principles {
297
if enabled == principle {
298
stated = append(stated, principleText[principle])
299
}
300
}
301
}
302
return stated
303
}
305
// Allows reports whether an intrinsic class is enabled.
306
func (p Policy) Allows(intrinsic Intrinsic) bool {
307
for _, allowed := range p.Spec.Comments.Intrinsic {
308
if allowed == intrinsic {
309
return true
310
}
311
}
312
return false
313
}
315
// Excludes reports whether a generated or vendored path is outside enforcement.
316
func (p Policy) Excludes(file string) bool {
317
for _, pattern := range append(append([]string{}, p.Spec.Comments.GeneratedPaths...), p.Spec.Comments.VendoredPaths...) {
318
if matches(pattern, file) {
319
return true
320
}
321
}
322
return false
323
}
325
func validateIntrinsics(values []Intrinsic) error {
326
allowed := map[Intrinsic]bool{
327
IntrinsicToolchain: true, IntrinsicGeneratedMarker: true, IntrinsicUpstreamLink: true,
328
IntrinsicDeprecated: true, IntrinsicPublicAPI: true,
329
}
330
seen := map[Intrinsic]bool{}
331
for _, value := range values {
332
if !allowed[value] {
333
return fmt.Errorf("spec.comments.intrinsic contains unsupported class %q", value)
334
}
335
if seen[value] {
336
return fmt.Errorf("spec.comments.intrinsic contains %q more than once", value)
337
}
338
seen[value] = true
339
}
340
return nil
341
}
343
func validatePrinciples(values []Principle) error {
344
seen := map[Principle]bool{}
345
for _, value := range values {
346
if _, known := principleText[value]; !known {
347
return fmt.Errorf("spec.agents.principles contains unsupported principle %q", value)
348
}
349
if seen[value] {
350
return fmt.Errorf("spec.agents.principles contains %q more than once", value)
351
}
352
seen[value] = true
353
}
354
return nil
355
}
357
func validateAdapters(values []Adapter) error {
358
allowed := map[Adapter]bool{
359
AdapterAgents: true, AdapterClaude: true, AdapterCopilot: true,
360
AdapterCursor: true, AdapterCodex: true, AdapterOpencode: true,
361
}
362
seen := map[Adapter]bool{}
363
for _, value := range values {
364
if !allowed[value] {
365
return fmt.Errorf("spec.agents.adapters contains unsupported adapter %q", value)
366
}
367
if seen[value] {
368
return fmt.Errorf("spec.agents.adapters contains %q more than once", value)
369
}
370
seen[value] = true
371
}
372
return nil
373
}
375
func validateGlobs(field string, patterns []string) error {
376
for _, pattern := range patterns {
377
switch {
378
case pattern == "":
379
return fmt.Errorf("%s contains an empty pattern", field)
380
case strings.Contains(pattern, `\`):
381
return fmt.Errorf("%s pattern %q must use forward slashes", field, pattern)
382
case strings.HasPrefix(pattern, "/"):
383
return fmt.Errorf("%s pattern %q must be repository-relative", field, pattern)
384
case strings.Contains("/"+pattern+"/", "/../"):
385
return fmt.Errorf("%s pattern %q escapes the repository", field, pattern)
386
}
387
if _, err := globExpression(pattern); err != nil {
388
return fmt.Errorf("%s pattern %q: %w", field, pattern, err)
389
}
390
}
391
return nil
392
}
394
func matches(pattern, file string) bool {
395
expression, err := globExpression(pattern)
396
return err == nil && expression.MatchString(file)
397
}
399
func globExpression(pattern string) (*regexp.Regexp, error) {
400
var expression strings.Builder
401
expression.WriteString("^")
402
for index := 0; index < len(pattern); index++ {
403
character := pattern[index]
404
switch character {
405
case '*':
406
if index+1 < len(pattern) && pattern[index+1] == '*' {
407
index++
408
if index+1 < len(pattern) && pattern[index+1] == '/' {
409
index++
410
expression.WriteString("(?:.*/)?")
411
} else {
412
expression.WriteString(".*")
413
}
414
} else {
415
expression.WriteString("[^/]*")
416
}
417
case '?':
418
expression.WriteString("[^/]")
419
default:
420
expression.WriteString(regexp.QuoteMeta(string(character)))
421
}
422
}
423
expression.WriteString("$")
424
return regexp.Compile(expression.String())
425
}
427
func writeAtomically(root *os.Root, name string, content []byte) error {
428
var entropy [8]byte
429
if _, err := rand.Read(entropy[:]); err != nil {
430
return fmt.Errorf("creating temporary name for %s: %w", name, err)
431
}
432
temporaryName := name + "." + hex.EncodeToString(entropy[:])
433
temporary, err := root.OpenFile(temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
434
if err != nil {
435
return fmt.Errorf("creating temporary file beside %s: %w", name, err)
436
}
437
defer func() { _ = root.Remove(temporaryName) }()
438
if _, err := temporary.Write(content); err != nil {
439
_ = temporary.Close()
440
return fmt.Errorf("writing %s: %w", temporaryName, err)
441
}
442
if err := temporary.Close(); err != nil {
443
return fmt.Errorf("closing %s: %w", temporaryName, err)
444
}
445
if err := root.Rename(temporaryName, name); err != nil {
446
return fmt.Errorf("replacing %s: %w", name, err)
447
}
448
return nil
449
}