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
"github.com/koment-dev/koment/internal/store"
18
)
20
const (
21
// APIVersion is matched exactly, exactly as an annotation record's is.
22
APIVersion = api.Version
24
// KindPolicy is the resource kind of a repository policy.
25
KindPolicy = "Policy"
27
// LegacyVersion is the only value the pre-v1alpha `version` field ever
28
// carried. koment no longer reads that shape; the constant survives so a
29
// policy still carrying it is refused by name rather than mistaken for a
30
// malformed file (ADR 0130).
31
LegacyVersion = 1
33
ModeStrict = "strict"
34
FileName = ".koment/policy.yaml"
35
SchemaURL = api.SchemaBase + "policy.schema.json"
36
)
38
// Intrinsic names one class of source comment that may remain inline.
39
type Intrinsic string
41
const (
42
IntrinsicToolchain Intrinsic = "toolchain-directive"
43
IntrinsicGeneratedMarker Intrinsic = "generated-marker"
44
IntrinsicUpstreamLink Intrinsic = "upstream-link"
45
IntrinsicDeprecated Intrinsic = "deprecated"
46
IntrinsicPublicAPI Intrinsic = "public-api"
47
)
49
// Principle names one extra rule the generated agent contract states. A
50
// principle is a claim a reviewer can check, not a preference.
51
type Principle string
53
const (
54
PrincipleBackCompatEvidence Principle = "back-compat-evidence"
55
)
57
var Principles = []Principle{PrincipleBackCompatEvidence}
59
var principleText = map[Principle]string{
60
PrincipleBackCompatEvidence: "A back-compatibility claim needs evidence: a migration path the binary performs, " +
61
"or an ADR naming the version the old shape was cut off at. Without either, the change is breaking " +
62
"and its commit subject says so with `feat!:`.",
63
}
65
// Adapter names one generated agent instruction surface.
66
type Adapter string
68
const (
69
AdapterAgents Adapter = "agents"
70
AdapterClaude Adapter = "claude"
71
AdapterCopilot Adapter = "copilot"
72
AdapterCursor Adapter = "cursor"
73
AdapterCodex Adapter = "codex"
74
AdapterOpencode Adapter = "opencode"
75
)
77
// Policy is the repository enforcement contract, shaped like every other
78
// committed koment resource (ADR 0121).
79
type Policy struct {
80
APIVersion string `yaml:"apiVersion"`
81
Kind string `yaml:"kind"`
82
Spec Spec `yaml:"spec"`
83
}
85
// Spec is everything the repository decided. A policy has no metadata because
86
// it is a singleton with no identity of its own: the file path is the name.
87
type Spec struct {
88
Comments CommentsPolicy `yaml:"comments"`
89
Agents AgentsPolicy `yaml:"agents"`
90
}
92
// CommentsPolicy configures strict classification and repository exclusions.
93
type CommentsPolicy struct {
94
Mode string `yaml:"mode"`
95
Intrinsic []Intrinsic `yaml:"intrinsic"`
96
AllowedAnnotations []string `yaml:"allowedAnnotations,omitempty"`
97
GeneratedPaths []string `yaml:"generatedPaths,omitempty"`
98
VendoredPaths []string `yaml:"vendoredPaths,omitempty"`
99
}
101
// AgentsPolicy selects generated instruction adapters and the principles they
102
// state.
103
type AgentsPolicy struct {
104
Adapters []Adapter `yaml:"adapters"`
105
Principles []Principle `yaml:"principles,omitempty"`
106
}
108
// Activation is the policy and root of a repository that opted into enforcement.
109
type Activation struct {
110
Root string
111
Configured Policy
112
}
114
// Default returns the strict policy installed for a new repository.
115
func Default() Policy {
116
return Policy{
117
APIVersion: APIVersion,
118
Kind: KindPolicy,
119
Spec: Spec{
120
Comments: CommentsPolicy{
121
Mode: ModeStrict,
122
Intrinsic: []Intrinsic{
123
IntrinsicToolchain, IntrinsicGeneratedMarker, IntrinsicUpstreamLink,
124
IntrinsicDeprecated, IntrinsicPublicAPI,
125
},
126
GeneratedPaths: DefaultGeneratedPaths(),
127
VendoredPaths: DefaultVendoredPaths(),
128
},
129
Agents: AgentsPolicy{
130
Adapters: []Adapter{
131
AdapterAgents, AdapterClaude, AdapterCopilot, AdapterCursor, AdapterCodex,
132
AdapterOpencode,
133
},
134
Principles: []Principle{PrincipleBackCompatEvidence},
135
},
136
},
137
}
138
}
140
// Load reads and strictly validates the repository policy.
141
func Load(rootPath string) (configured Policy, returnedError error) {
142
root, err := os.OpenRoot(rootPath)
143
if err != nil {
144
return Policy{}, fmt.Errorf("opening repository root %s: %w", rootPath, err)
145
}
146
defer func() {
147
if closeErr := root.Close(); closeErr != nil {
148
returnedError = errors.Join(returnedError, closeErr)
149
}
150
}()
151
content, err := root.ReadFile(FileName)
152
if err != nil {
153
return Policy{}, fmt.Errorf("reading %s: %w", FileName, err)
154
}
155
configured, decodeErr := decode(content)
156
if decodeErr != nil {
157
return Policy{}, decodeErr
158
}
159
if err := configured.Validate(); err != nil {
160
return Policy{}, fmt.Errorf("in %s: %w", FileName, err)
161
}
162
return configured, nil
163
}
165
// Detect returns nil when automatic koment enforcement is inactive at start.
166
func Detect(start string) (*Activation, error) {
167
rootPath, err := store.FindRoot(start)
168
if err != nil {
169
return nil, nil
170
}
171
configured, err := Load(rootPath)
172
if err == nil {
173
return &Activation{Root: rootPath, Configured: configured}, nil
174
}
175
if !errors.Is(err, os.ErrNotExist) {
176
return nil, err
177
}
178
hasAnnotations, err := store.Open(rootPath).HasAnnotationRecords()
179
if err != nil {
180
return nil, err
181
}
182
if hasAnnotations {
183
return nil, fmt.Errorf("%s contains annotation records but %s is missing; run `koment bootstrap`", path.Join(store.DirName, "annotations"), FileName)
184
}
185
return nil, nil
186
}
188
type policyShape struct {
189
APIVersion string `yaml:"apiVersion"`
190
Version *int `yaml:"version"`
191
}
193
func decode(content []byte) (Policy, error) {
194
var shape policyShape
195
if err := yaml.Unmarshal(content, &shape); err != nil {
196
return Policy{}, fmt.Errorf("parsing %s: %w", FileName, err)
197
}
198
switch {
199
case shape.APIVersion == APIVersion:
200
var configured Policy
201
if err := decodeOneDocument(content, &configured); err != nil {
202
return Policy{}, err
203
}
204
return configured, nil
205
case shape.APIVersion != "":
206
return Policy{}, fmt.Errorf(
207
"incompatible %s: apiVersion %q is not supported; this binary reads %s (ADR 0121)",
208
FileName, shape.APIVersion, APIVersion)
209
case shape.Version != nil && *shape.Version == LegacyVersion:
210
return Policy{}, fmt.Errorf(
211
"incompatible %s: this policy is in the pre-v1alpha `version: %d` shape, which koment no longer reads (ADR 0130). "+
212
"Read this repository once with koment 2.x, which rewrites it in the %s shape, then retry",
213
FileName, LegacyVersion, APIVersion)
214
default:
215
return Policy{}, fmt.Errorf(
216
"incompatible %s: no apiVersion; a koment policy starts with `apiVersion: %s` (ADR 0121)",
217
FileName, APIVersion)
218
}
219
}
221
func decodeOneDocument(content []byte, into any) error {
222
decoder := yaml.NewDecoder(strings.NewReader(string(content)))
223
decoder.KnownFields(true)
224
if err := decoder.Decode(into); err != nil {
225
return fmt.Errorf("parsing %s: %w", FileName, err)
226
}
227
var trailing any
228
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
229
if err == nil {
230
return fmt.Errorf("parsing %s: multiple YAML documents are not allowed", FileName)
231
}
232
return fmt.Errorf("parsing %s after the policy: %w", FileName, err)
233
}
234
return nil
235
}
237
// Install writes the default policy only when none exists.
238
func Install(rootPath string) (Policy, bool, error) {
239
configured, err := Load(rootPath)
240
if err == nil {
241
return configured, false, nil
242
}
243
if !errors.Is(err, os.ErrNotExist) {
244
return Policy{}, false, err
245
}
246
configured = Default()
247
if err := Save(rootPath, configured); err != nil {
248
return Policy{}, false, err
249
}
250
return configured, true, nil
251
}
253
// Save writes a validated policy atomically beneath the repository root.
254
func Save(rootPath string, configured Policy) (returnedError error) {
255
if err := configured.Validate(); err != nil {
256
return err
257
}
258
root, err := os.OpenRoot(rootPath)
259
if err != nil {
260
return fmt.Errorf("opening repository root %s: %w", rootPath, err)
261
}
262
defer func() {
263
if closeErr := root.Close(); closeErr != nil {
264
returnedError = errors.Join(returnedError, closeErr)
265
}
266
}()
267
return writeTo(root, configured)
268
}
270
func writeTo(root *os.Root, configured Policy) error {
271
encoded, err := encode(configured)
272
if err != nil {
273
return err
274
}
275
if err := root.MkdirAll(path.Dir(FileName), 0o755); err != nil {
276
return fmt.Errorf("creating %s: %w", path.Dir(FileName), err)
277
}
278
return writeAtomically(root, FileName, encoded)
279
}
281
func encode(configured Policy) ([]byte, error) {
282
var encoded strings.Builder
283
encoded.WriteString("# yaml-language-server: $schema=" + SchemaURL + "\n")
284
encoder := yaml.NewEncoder(&encoded)
285
encoder.SetIndent(2)
286
if err := encoder.Encode(configured); err != nil {
287
return nil, fmt.Errorf("encoding %s: %w", FileName, err)
288
}
289
if err := encoder.Close(); err != nil {
290
return nil, fmt.Errorf("encoding %s: %w", FileName, err)
291
}
292
return []byte(encoded.String()), nil
293
}
295
// Validate rejects policy drift and unsupported bypasses.
296
func (p Policy) Validate() error {
297
if p.APIVersion != APIVersion {
298
return fmt.Errorf("apiVersion %q, want %q", p.APIVersion, APIVersion)
299
}
300
if p.Kind != KindPolicy {
301
return fmt.Errorf("kind %q, want %q", p.Kind, KindPolicy)
302
}
303
if p.Spec.Comments.Mode != ModeStrict {
304
return fmt.Errorf("spec.comments.mode %q, want %q", p.Spec.Comments.Mode, ModeStrict)
305
}
306
if err := validateIntrinsics(p.Spec.Comments.Intrinsic); err != nil {
307
return err
308
}
309
if err := validateAllowedAnnotations(p.Spec.Comments.AllowedAnnotations); err != nil {
310
return err
311
}
312
if err := validateGlobs("spec.comments.generatedPaths", p.Spec.Comments.GeneratedPaths); err != nil {
313
return err
314
}
315
if err := validateGlobs("spec.comments.vendoredPaths", p.Spec.Comments.VendoredPaths); err != nil {
316
return err
317
}
318
if err := validatePrinciples(p.Spec.Agents.Principles); err != nil {
319
return err
320
}
321
return validateAdapters(p.Spec.Agents.Adapters)
322
}
324
// States returns the wording of every principle this policy enables, in the
325
// order the vocabulary declares them so that a regenerated contract does not
326
// diff against itself.
327
func (p Policy) States() []string {
328
stated := make([]string, 0, len(p.Spec.Agents.Principles))
329
for _, principle := range Principles {
330
for _, enabled := range p.Spec.Agents.Principles {
331
if enabled == principle {
332
stated = append(stated, principleText[principle])
333
}
334
}
335
}
336
return stated
337
}
339
// Allows reports whether an intrinsic class is enabled.
340
func (p Policy) Allows(intrinsic Intrinsic) bool {
341
for _, allowed := range p.Spec.Comments.Intrinsic {
342
if allowed == intrinsic {
343
return true
344
}
345
}
346
return false
347
}
349
// DefaultGeneratedPaths lists the machine-written files no author is
350
// accountable for, across the languages koment reads (ADR 0132).
351
func DefaultGeneratedPaths() []string {
352
return []string{
353
"**/*.gen.go", "**/*.generated.go", "**/*.pb.go",
354
"**/*_pb2.py", "**/*_pb2.pyi",
355
"**/*.min.js", "**/*.min.css", "**/*.bundle.js",
356
}
357
}
359
// DefaultVendoredPaths lists the dependency and build directories that hold
360
// somebody else's code. Scanning them reports tens of thousands of comments
361
// nobody in this repository can act on (ADR 0132).
362
func DefaultVendoredPaths() []string {
363
return []string{
364
"**/vendor/**", "**/node_modules/**", "**/third_party/**",
365
"**/.venv/**", "**/venv/**", "**/__pycache__/**", "**/.tox/**",
366
"**/target/**", "**/build/**", "**/dist/**", "**/out/**",
367
"**/.gradle/**", "**/.next/**", "**/.cache/**",
368
".koment/**", "**/.koment/**",
369
}
370
}
372
// Excludes reports whether a generated or vendored path is outside enforcement.
373
func (p Policy) Excludes(file string) bool {
374
for _, pattern := range append(append([]string{}, p.Spec.Comments.GeneratedPaths...), p.Spec.Comments.VendoredPaths...) {
375
if matches(pattern, file) {
376
return true
377
}
378
}
379
return false
380
}
382
func validateIntrinsics(values []Intrinsic) error {
383
allowed := map[Intrinsic]bool{
384
IntrinsicToolchain: true, IntrinsicGeneratedMarker: true, IntrinsicUpstreamLink: true,
385
IntrinsicDeprecated: true, IntrinsicPublicAPI: true,
386
}
387
seen := map[Intrinsic]bool{}
388
for _, value := range values {
389
if !allowed[value] {
390
return fmt.Errorf("spec.comments.intrinsic contains unsupported class %q", value)
391
}
392
if seen[value] {
393
return fmt.Errorf("spec.comments.intrinsic contains %q more than once", value)
394
}
395
seen[value] = true
396
}
397
return nil
398
}
400
func validateAllowedAnnotations(patterns []string) error {
401
for _, pattern := range patterns {
402
if pattern == "" {
403
return fmt.Errorf("spec.comments.allowedAnnotations contains an empty pattern")
404
}
405
if _, err := regexp.Compile(pattern); err != nil {
406
return fmt.Errorf("spec.comments.allowedAnnotations pattern %q does not compile: %w", pattern, err)
407
}
408
}
409
return nil
410
}
412
// MatchesAllowedAnnotation reports whether body matches any pattern in
413
// spec.comments.allowedAnnotations. Invalid patterns are silently skipped;
414
// Validate is the place that rejects them.
415
func (p Policy) MatchesAllowedAnnotation(body string) bool {
416
for _, pattern := range p.Spec.Comments.AllowedAnnotations {
417
re, err := regexp.Compile(pattern)
418
if err != nil {
419
continue
420
}
421
if re.MatchString(body) {
422
return true
423
}
424
}
425
return false
426
}
428
func validatePrinciples(values []Principle) error {
429
seen := map[Principle]bool{}
430
for _, value := range values {
431
if _, known := principleText[value]; !known {
432
return fmt.Errorf("spec.agents.principles contains unsupported principle %q", value)
433
}
434
if seen[value] {
435
return fmt.Errorf("spec.agents.principles contains %q more than once", value)
436
}
437
seen[value] = true
438
}
439
return nil
440
}
442
func validateAdapters(values []Adapter) error {
443
allowed := map[Adapter]bool{
444
AdapterAgents: true, AdapterClaude: true, AdapterCopilot: true,
445
AdapterCursor: true, AdapterCodex: true, AdapterOpencode: true,
446
}
447
seen := map[Adapter]bool{}
448
for _, value := range values {
449
if !allowed[value] {
450
return fmt.Errorf("spec.agents.adapters contains unsupported adapter %q", value)
451
}
452
if seen[value] {
453
return fmt.Errorf("spec.agents.adapters contains %q more than once", value)
454
}
455
seen[value] = true
456
}
457
return nil
458
}
460
func validateGlobs(field string, patterns []string) error {
461
for _, pattern := range patterns {
462
switch {
463
case pattern == "":
464
return fmt.Errorf("%s contains an empty pattern", field)
465
case strings.Contains(pattern, `\`):
466
return fmt.Errorf("%s pattern %q must use forward slashes", field, pattern)
467
case strings.HasPrefix(pattern, "/"):
468
return fmt.Errorf("%s pattern %q must be repository-relative", field, pattern)
469
case strings.Contains("/"+pattern+"/", "/../"):
470
return fmt.Errorf("%s pattern %q escapes the repository", field, pattern)
471
}
472
if _, err := globExpression(pattern); err != nil {
473
return fmt.Errorf("%s pattern %q: %w", field, pattern, err)
474
}
475
}
476
return nil
477
}
479
func matches(pattern, file string) bool {
480
expression, err := globExpression(pattern)
481
return err == nil && expression.MatchString(file)
482
}
484
func globExpression(pattern string) (*regexp.Regexp, error) {
485
var expression strings.Builder
486
expression.WriteString("^")
487
for index := 0; index < len(pattern); index++ {
488
character := pattern[index]
489
switch character {
490
case '*':
491
if index+1 < len(pattern) && pattern[index+1] == '*' {
492
index++
493
if index+1 < len(pattern) && pattern[index+1] == '/' {
494
index++
495
expression.WriteString("(?:.*/)?")
496
} else {
497
expression.WriteString(".*")
498
}
499
} else {
500
expression.WriteString("[^/]*")
501
}
502
case '?':
503
expression.WriteString("[^/]")
504
default:
505
expression.WriteString(regexp.QuoteMeta(string(character)))
506
}
507
}
508
expression.WriteString("$")
509
return regexp.Compile(expression.String())
510
}
512
func writeAtomically(root *os.Root, name string, content []byte) error {
513
var entropy [8]byte
514
if _, err := rand.Read(entropy[:]); err != nil {
515
return fmt.Errorf("creating temporary name for %s: %w", name, err)
516
}
517
temporaryName := name + "." + hex.EncodeToString(entropy[:])
518
temporary, err := root.OpenFile(temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
519
if err != nil {
520
return fmt.Errorf("creating temporary file beside %s: %w", name, err)
521
}
522
defer func() { _ = root.Remove(temporaryName) }()
523
if _, err := temporary.Write(content); err != nil {
524
_ = temporary.Close()
525
return fmt.Errorf("writing %s: %w", temporaryName, err)
526
}
527
if err := temporary.Close(); err != nil {
528
return fmt.Errorf("closing %s: %w", temporaryName, err)
529
}
530
if err := root.Rename(temporaryName, name); err != nil {
531
return fmt.Errorf("replacing %s: %w", name, err)
532
}
533
return nil
534
}